Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

static abstract methods and properties #34516

Open
5 tasks done
RyanCavanaugh opened this issue Oct 16, 2019 · 84 comments
Open
5 tasks done

static abstract methods and properties #34516

RyanCavanaugh opened this issue Oct 16, 2019 · 84 comments
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Oct 16, 2019

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();
  • Pros: Ergonomic
  • Cons: Literally allows the runtime-crashing code A.doSomething(), which seems like a fairly large design deficit

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();
  • Pros: Correctly prevents all crashes
  • Cons: Extremely unergonomic at use cases; effectively bans concrete static methods from calling same-class abstract methods

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.

  • Pros: Catches "bad by inspection" instances while still allowing "maybe it works" code
  • Cons: Extremely inconsistent; simply appears to function as if TypeScript has a bug in it. Unclear what sufficient indirection means in cases of e.g. module imports

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

  • Pros: Maybe a slightly better variant of option 3
  • Cons: Just a more complicated system with the same failure modes

Option 5: Something else?

Anyone able to square this circle?

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. labels Oct 16, 2019
@AlCalzone
Copy link
Contributor

Use Cases

(what are they?)

https://github.com/AlCalzone/node-zwave-js/blob/d2e29322d0392e02b7d8e2d7c8c430cb8fcaa113/src/lib/commandclass/CommandClass.ts#L363 and especially this comment:
https://github.com/AlCalzone/node-zwave-js/blob/d2e29322d0392e02b7d8e2d7c8c430cb8fcaa113/src/lib/commandclass/CommandClass.ts#L368

In the Z-Wave protocol most of the functionality is in specific command classes. The linked class serves as a common base class for them. It should be abstract but due to current limitations it is not.

Therefore I have these dummy implementations that just say "override me in a derived class if necessary". I'd much rather have them defined as abstract methods, so derived classes have to be explicit if they need an implementation for the method or not.

@thw0rted
Copy link

I haven't tested this but doesn't

  static createInstance() {
    const a = new this();
    // Error
    this.initialize(a);
    return a;
  }

actually crash on the line before the "Error" comment, because in A.createInstance(), this is typeof A, and you can't call new on an abstract class?

@thw0rted
Copy link

thw0rted commented Oct 17, 2019

Reading through these options, the thing I keep coming back to is that you'd solve the problem if you could guarantee that the class being passed as a function/generic argument is concrete. I don't think there's an existing constraint for that, is there? Like, if I could write

function fn<T extends Concrete<typeof A>>(x: T) {
    x.doSomething();
}

where Concrete uses conditional type math to be never if the generic argument is abstract. I don't quite have it figured out myself but it feels like something @dragomirtitian could come up with 🤔

ETA: of course if a keyword were added that means "and is not abstract" that would also be a good resolution. Right?

@fatcerberus
Copy link

[...] actually crash on the line before the "Error" comment

No it does not because abstract has no effect at runtime--it's purely compile-time info. The class is perfectly newable at runtime as the abstractness is only enforced by the compiler.

@thw0rted
Copy link

Great point, I honestly forgot that native ES6 classes don't have an abstract keyword. It looks like the workaround would be to check new.target in the abstract class's constructor, or see if nominally-abstract methods actually exist, and throw explicitly, but that would be a discussion for another issue.

@fatcerberus
Copy link

That would likely fall under type-directed emit and therefore is a nonstarter.

@dragomirtitian
Copy link
Contributor

dragomirtitian commented Oct 20, 2019

@thw0rted I think the best alternative for the createInstance and initialize case is just to be upfront about the requirements of the class on the createInstance function. We can explicitly type this and make sure the passed in class is a concrete class derived from A (ie has a callable constructor, that returns A) and has any extra needed static methods:

interface FooStatic<T extends Foo> {
    new(): T;
    initialize(o: T): void
}

abstract class Foo {

    static createInstance<T extends Foo>(this: FooStatic<T>) {
        const a = new this();
        
        this.initialize(a);
        return a;
    }
}

Foo.createInstance() // error Foo is abstract and does not implement initialize

class Bar extends Foo { }
Bar.createInstance() //error Bar does not implement initialize

abstract class Baz extends Foo { static initialize(o: Baz) { } }
Baz.createInstance() //error Baz is abstract

class Ok extends Foo { static initialize(o: Ok) { } }
Ok.createInstance() // finally ok 

Play

While initialize is not an abstract static member of Foo, it acts like one for any client that calls createInstance. This makes a clear choice regarding the question 'What calls of abstract static methods are allowed?'. Foo.initialize is not allowed as Foo does not really have the static method. Any method in Foo that requires access to initialize must be explicit about this and have an annotation for this.

The version above does not allow access to any statics Foo defined, but this can be easily remedied with an intersection (ex)

While unfortunately this does not throw errors on class declaration, it does guarantee that any function Foo that requires extra static methods is not callable on any defined class that does not define them. I personally think this is close enough, but results may vary 😊.

@thw0rted
Copy link

This is the first time I've heard explicit this typing suggested as a resolution for the issue, and I think it does have a lot going for it. I maintain that it would be "nice" to have some declaration inside abstract class Foo { } that tells implementing classes that they must implement a static initialize(), but it seems like keeping the instance and constructor halves of a class in the same block is an argument I already lost. (That's the other "child issue" of #14600, BTW.)

@dragomirtitian
Copy link
Contributor

@thw0rted The explicit this typing is just because we want to use the 'abstract' statics inside the abstract class. If createInstance were a regular function taking a class as a parameter, the types would look the same, just be applied to a regular parameter:

interface FooStatic<T extends Foo> {
    new(): T;
    initialize(o: T): void
}

abstract class Foo {
    private x; 
}

function createInstance<T extends Foo>(cls: FooStatic<T>) {
    const a = new cls();
    
    cls.initialize(a);
    return a;
}

Play

@CreatedBySeb
Copy link

CreatedBySeb commented Jan 4, 2020

Just popping in with a use case. I am writing a system that deals with dynamically loading modules at the moment, where the module provides a default export which is an instance of an abstract class Extension. I would like to have an abstract static property on Extension which defines metadata about the extension, but this would differ for each extension, so I want to require it but not implement it on the abstract class. The reason I want this to be abstract, is so that I can check several key properties in the metadata before instantiating the class in any form (mostly for dependency resolution).

Here's a code snippet to try to explain what I mean:

interface ExtensionManifest {
    identifier: string;
    ...
    dependsOn?: string[];
}

abstract class Extension {
    static abstract MANIFEST: ExtensionManifest;
}

class ExtensionA extends Extension {
    static MANIFEST = {
        identifier: "extension-a";
    }
} // Ok, correctly implements static property

class ExtensionB extends Extension {
    static MANIFEST = {
        dependsOn: ["extension-a"];
    }
} // Error, static property MANIFEST does not fully implement ExtensionManifest

class ExtensionC extends Extension {
} // Error, static property MANIFEST does not exist on ExtensionC

@CreatedBySeb
Copy link

Also @RyanCavanaugh, I may be misunderstanding things, but on the flaws with option 3 in your original post it appears typeof p returns typeof A (looking at VSCode intellisense), where as if you have a class that correctly extends A (C for the purposes of this example), typeof C returns C, so is it not possible to discern the indirection (as typeof p resolves to typeof A anyways, which would be disallowed in this model)? I may be completely wrong here as I am not very familiar with the internals of the TypeScript engine, this just seems to be the case from experimenting within VSCode. This doesn't address the issue regarding import aliases with this model that you raised however.

@eddiemf
Copy link

eddiemf commented Apr 24, 2020

No more progress on this issue? It's been 3 years already 😞

@RyanCavanaugh
Copy link
Member Author

@eddiemf which of the five proposals listed in the OP do you think we should be progressing on, and why?

@eddiemf
Copy link

eddiemf commented Apr 27, 2020

@RyanCavanaugh After going more deeply into the whole thread I can understand why it's been 3 years already 😅

I don't really agree with any of the current possible solutions and I also can't think of something better.

My use case was just to enforce the implementation of the method in the subclass, but I can see it goes way beyond that for various reasons. And by the looks of it the thread about allowing static methods on interfaces is also stuck 😔

Well, it serves as a bump at least

@IsaiahByDayah
Copy link

Commenting for future update notifications. Would love this feature as well. Also looking to enforce implementation of the method in subclasses 😅

@manzt
Copy link

manzt commented May 2, 2020

Likewise. I've been through these threads several times and see so many conflicting things. Is there a clear work-around for achieving the Serializable abstract class as previously described? Apologies if I've missed something...

abstract class Serializable {  
    abstract serialize (): Object;  
    abstract static deserialize (Object): Serializable;  
}

EDIT: Solution I've gone with for the moment

abstract class Serializable {  
    abstract serialize (): Object;  
}

class A implements Serializable {
   serialize(): Object { return obj as Object; };
   static deserialize(obj: Object): A { return new A() };
}

function useDeserialize<T extends Serializable>(
  obj: Object, 
  serializable: { deserialize(obj: Object) }: T
): T {
  return serializable.deserialize(obj);
}

useDeserialize(A);

@minecrawler
Copy link

minecrawler commented Jun 7, 2020

Since I also just stumbled on this, here is my wishes from a user-perspective. I want to be able to

  • write abstract static methods
  • call non-abstract methods on the abstract class from outside the same class
  • call abstract methods from non-abstract methods inside the same class

However, none of the above options provides this functionality in a safe way. So, here is an idea: why not make the checks a bit clever. In general, we expect an abstract class to be inherited and fully implemented, and especially abstract methods and non-abstract methods calling abstract methods only make sense in that context. So that's what the compiler should provide.

  1. Obviously, if there is an abstract method, it should not exist on the object from the outside.
  2. Any non-abstract method which calls an abstract method, should be treated as abstract on the outside.

Here's an example with comments how that would look like in code:

abstract class A {
  // obviously abstract, so A.foo() does not exist!
  abstract static foo(): void

  // externally abstract on A, so A.bar() does not exist!
  static bar(): void {
    this.foo(); // works, because we expect `this` to be a child implementation
  }
}

A.foo(); // ERROR: foo() does not exist on A (because it is directly abstract)
A.bar(); // ERROR: bar() does not exist on A (because it is indirectly abstract)


class B extends A {
  // not abstract anymore, so B.foo() exists
  static foo(): void {}
}

B.foo(); // WORKS
B.bar(); // WORKS, because B.foo() is not abstract

@thw0rted
Copy link

thw0rted commented Jun 9, 2020

If anybody is still following this: I just linked here from another issue. It looks like this really isn't going anywhere, and neither is #33892. Is there another way to constrain a generic type parameter to say "this can only be generic on types that implement a static method f(number, boolean)"?

@arantes555
Copy link

arantes555 commented Jun 9, 2020

@thw0rted Yeah but you have to work around a bit. You can do something like

const func = <T extends {new(): YourClass, func (whatyouwant): whatyouwant}> (instance: InstanceType<T>) => {}

@thw0rted
Copy link

thw0rted commented Jun 9, 2020

I don't think I'm getting it. Check out this Playground example.

I think I have to pass separate instance-type and class-type generic parameters, since I'm not hard-coding "YourClass" as in your example. I have to grab the static serialize implementation from val.constructor but I can't guarantee that that exists. And the negative test at the bottom, trying to wrap an instance of a class with no serialize method, doesn't actually fail. I must be missing something.

@arantes555
Copy link

@thw0rted check this

@jcalz
Copy link
Contributor

jcalz commented Jun 9, 2020

That doesn't infer the constructor type properly, so new Wrapper(new Bar()) does not error when it should. Unless #3841 is addressed there's not much chance of getting the behavior you want without a type assertion or manually annotating your classes as having a strongly-typed constructor property.

@thw0rted
Copy link

thw0rted commented Jun 9, 2020

@arantes555 it looks like InstanceType<WrappedTypeConstructor> just winds up being any -- look at the return type of the call to getValue() on L26 in your example.

@jcalz you're right, if I pass typeof Foo explicitly, it works, and if I pass typeof Bar explicitly it fails. I want the constructor argument to get flagged as invalid, though, and it sounds like that isn't going to happen.

Is the thing I'm trying to describe some kind of uncommon pattern, or an antipattern, or something? Some of the "rough edges" in TS I've found lately are because I'm trying to describe something weird or inadvisable, but this seems pretty straightforward and it's hard to believe it simply can't be done.

@RyanCavanaugh
Copy link
Member Author

Here's a rough sketch of "quasi-abstract" based on @minecrawler's suggestion that you can use today:

// Names for clarity
type A_Static = typeof A;
interface A_Static_Concrete extends A_Static {
    bar(): void;
}

// N.B. classes need not be abstract for this pattern
class A {
    static foo(this: A_Static_Concrete) {
        // OK
        this.bar();
    }
}

// Concrete now
class B extends A {
    static bar() {

    }
}

// Error
A.foo();
// OK
B.foo();

@Rigidity
Copy link

Rigidity commented Jun 7, 2022

Any update or thoughts on this?

@RyanCavanaugh
Copy link
Member Author

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

@Rigidity
Copy link

Rigidity commented Jun 7, 2022

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:

  • Determine if a static abstract method is called from the static method and mark it as so internally
  • If you attempt to call it on the super-class, throw an error such as "Cannot call static abstract method doSomething on class A, as it relies on behavior that is only implemented in subclasses."

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
Copy link
Member Author

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?

  • If it's at the invocation of x.create, then there's no way to refer to the general idea of "a class constructor that looks like Foo but has no abstract static members"
  • If it's the assignment of Foo to typeof Foo, what exactly does typeof Foo mean anymore?
  • If it's not there, then where?

@thw0rted
Copy link

thw0rted commented Jun 8, 2022

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
Copy link

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
Copy link

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

@trickypr
Copy link

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

  • This would break the behavior of current abstract class with static methods. I am not entirely sure what the policy on breaking changes is, but I suspect that it is something like "avoid them"
  • Would it be more ergonomic for Extends<T> to be a keyword instead (e.g. extends typeof Base)?
  • Is there a preference for Extends<T> vs Concrete<T> vs something else?

@dead-claudia
Copy link

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:

  • Abstract properties carry the same rules that optional properties would have if the --exactOptionalPropertyTypes was passed for the purpose of property access and assignability, except this is done regardless of whether that flag is passed.
  • Concrete (as in, without the abstract descriptor) properties are assignable to abstract properties, but abstract properties are not assignable to concrete properties. This ensures that abstractness can be handled transparently as needed.
  • Object type properties can be declared abstract just like readonly and other similar descriptors. (This is necessary to allow mapped types to manipulate them.)
  • Abstract properties cannot be accessed. Two helpers will exist to counter this:
    • type Concrete<T> = {-abstract [P in keyof T]: T[P]} to open up properties
    • type ConcreteClass<T extends abstract new (...args: any[]) => any, A extends any[]> = Concrete<T> & (new (...args: A) => InstanceType<T>) to allow dynamic constructor access
  • Abstract methods work the same way as optional concrete methods from a type perspective aside from the property itself being marked abstract.
  • Classes may declare static abstract properties, and object literals may declare abstract properties of their own. These declarations must not have corresponding values/implementations.
    • Abstract instance properties of classes are typed as concrete, as there's no way to access an object that lacks them.
  • Classes extending classes with abstract properties must either declare themselves as abstract (which among other things causes them to automatically inherit the definitions) or declare them explicitly (optionally as abstract properties if desired).
    • Note: this implies classes don't need to be declared abstract to have abstract static properties. (This can be enforced by a linter instead.) All the examples below with static properties are declared as abstract for clarity for similar reasons.

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<typeof A>) {
    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<I extends A<I>> {
  static abstract initialize<I extends A<I>>(self: I): void;
  static createInstance(this: ConcreteClass<typeof A, []>) {
    const a = new this();
    this.initialize(a as I);
    return a;
  }
}

class B extends A<B> {
  // 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<B> {
  // 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<typeof Complicated>) {
  // 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<typeof A>) {
    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<typeof A>) {
    // 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

This comment was marked as spam.

@clounie
Copy link

clounie commented Oct 1, 2022

Interesting to note / might provide some helpful contrast - Dart is chatting about the same problem dart-lang/language#356 (comment)

@tempusdave

This comment was marked as spam.

@RyanCavanaugh
Copy link
Member Author

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
Copy link

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
Copy link

TiesWestendorp commented Jan 24, 2023

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 (#34516 (comment)) 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

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
Copy link

bump

@Oaphi
Copy link

Oaphi commented Jul 14, 2023

@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
Copy link

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:
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:

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?

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.

fn(MyClass)

However, this code runs fine:

fn(MySubClass)

This by itself doesn't solve the issue, because concreteMember does not exist on new () => MyClass:

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:

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.

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<typeof MyClass>, or even Concrete<MyClass>. 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:
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.:

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:

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:

abstract class MyClass {}

function fn (X: typeof MyClass): MyClass {
  return new X() // Compiler error: Cannot create an instance of an abstract class.
}

fn(MyClass)
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:

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:

abstract class MyClass {
  abstract static foo (): string

  static bar (): string { 
    return this.foo() // Compiler error: Property 'foo' does not exist on type 'typeof MyClass'.
  }
}
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:

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:

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:

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests