Skip to content

Add support for abstract constructor types - #36392

Merged
rbuckton merged 9 commits into
masterfrom
abstractConstructSignatures
Jan 8, 2021
Merged

Add support for abstract constructor types#36392
rbuckton merged 9 commits into
masterfrom
abstractConstructSignatures

Conversation

@rbuckton

@rbucktonrbuckton commented Jan 24, 2020

Copy link
Copy Markdown
Contributor

This adds support for the abstract keyword on constructor types and construct signatures, allowing you to indicate the signature is abstract. This also updates the definitions for InstanceType and ConstructorParameters to use the abstract modifier:

Syntax

// constructor typestypeT1=abstractnew()=>any;

Semantics

  • Non-abstract constructor types are assignable to abstract constructor types if they would otherwise be assignable.
  • Abstract constructor types are not assignable to non-abstract constructor types.
  • Values with types containing abstract constructor types cannot be instantiated via new (this previously only applied to abstract classes)

Examples - Basic Usage

typeConstructor<T>=new(...args: any[])=>T;typeAbstractConstructor<T>=abstractnew(...args: any[])=>T;interfaceObj<T>{x: T;}declareletc: Constructor<Obj<number>>;declareleta: AbstractConstructor<Obj<number>>;// assignabilitya=c;// ok, a non-abstract constructor type can be assigned to an abstract constructor typec=a;// error, an abstract constructor type cannot be assigned to a non-abstract constructor type// new expressionnewc();// oknewa();// error, cannot create an instance of an abstract class// inference and conditional typestypeT1=InstanceType<typeofc>["x"];// numbertypeT2=InstanceType<typeofa>["x"];// number

Examples - Mixins

interfaceMixin{ ... }abstractclassBase{ ... }declarefunctionMixin1<TBaseextendsnew(...args: any[])=>{}>(base: TBase):
TBase&(new(...args: any[])=>Mixin);classSub1extendsMixin1(Base){}// ^^^^ TS2345: Argument of type 'typeof Base' is not assignable // to parameter of type 'new (...args: any[]) => {}'.// Cannot assign an abstract constructor type to a // non-abstract constructor type.declarefunctionMixin2<TBaseextendsabstractnew(...args: any[])=>{}>(base: TBase):
TBase&(new(...args: any[])=>Mixin);classSub2extendsMixin2(Base){}// Ok

Fixes#26829
Fixes#35576

@rbuckton

Copy link
Copy Markdown
ContributorAuthor

CC: @weswigham, @RyanCavanaugh, @DanielRosenwasser (as suggested reviewers is not working).

@ajafff

Copy link
Copy Markdown
Contributor

Does this also support mixins with abstract classes? Could you add a test for that, too?

@rbuckton
rbucktonforce-pushed the abstractConstructSignatures branch from 136dbf0 to 757b70fCompareJanuary 28, 2020 21:09
@rbuckton

Copy link
Copy Markdown
ContributorAuthor

I'm going to revert support for abstract construct signatures in favor of investigating class type expressions:

// [source.ts]functionMyMixin<TBaseClassextendsabstractnew(...args: any[])=>any>(baseClass: TBaseClass){abstractclassMyMixinClassextendsbaseClass{abstractabstractMethod(): void;}returnMyMixinClass;}// [output.d.ts]declarefunctionMyMixin<TBaseClassextendsabstractnew(...args: any[])=>any>(baseClass: TBaseClass): abstractclassextendsTBaseClass{abstractabstractMethod(): void;};

@sandersnsandersn added the Experiment A fork with an experimental idea which might not make it into master label Feb 1, 2020
@rbucktonrbuckton changed the title Add support for abstract constructor types and signaturesAdd support for abstract constructor typesJun 18, 2020
@rbuckton
rbucktonforce-pushed the abstractConstructSignatures branch from 757b70f to eb5789aCompareJuly 10, 2020 23:29
@rbuckton

Copy link
Copy Markdown
ContributorAuthor

Build failures seem to be due to the fact that eslint isn't handling the construct signatures having modifiers in lib/es5.d.ts so I'm reverting that part for now.

@rbuckton
rbucktonforce-pushed the abstractConstructSignatures branch from 0b9fd25 to 65a49a7CompareJuly 13, 2020 19:59
@trusktr

trusktr commented Jul 14, 2020

Copy link
Copy Markdown
Contributor
declarefunctionMyMixin<TBaseClassextendsabstractnew(...args: any[])=>any>(baseClass: TBaseClass)

If the constraint for TBaseClass is an abstract constructor, can we still base a non-abstract class for baseClass?

It won't be helpful if we're stuck with either abstract or not-abstract.

I think what people really want is that if they pass in an abstract class, then the mixin result is an abstract class (unless the mixin class implements the methods/properties), or if they pass a non-abstract class, then the mixin result is not abstract.

Otherwise the usability of mixins would be inflexible.

The following code has some type information omitted (like plain JS) so that I can clearly show the intention:

abstractclassFoo{abstractfooMethod(): void}functionCoolMixin(Base){returnclassCoolextendsBase{coolMethod(){}}}classMyClassextendsFoo{// Required to implement the abstract fooMethod here.// ...}classMyOtherClassextendsCoolMixin(Foo){// Should also be required to implement the abstract fooMethod here.// ...}

As a workaround, we could do the following but it is less ideal:

abstractclassFoo{abstractfooMethod(): void}functionCoolMixin(Base){returnclassCoolextendsBase{coolMethod(){}}}classMyClassextendsFoo{// Required to implement the abstract fooMethod here.// ...}class_MyOtherClassextendsFoo{// Required to implement the abstract fooMethod here.}classMyOtherClassextendsCoolMixin(_MyOtherClass){// ...}

And if the mixin returns an abstract class, it should inherit abstractedness. So,

abstractclassFoo{abstractfooMethod(): void}functionOtherMixin(Base){returnabstractclassOtherextendsBase{otherMethod(){}}}classMyClassextendsFoo{// Required to implement fooMethod here.// ...}classMyOtherClassextendsOtherMixin(Foo){// Required to implement fooMethod and otherMethod here.// ...}classBar{barMethod(){...}}// the previous OtherMixin application accepted an abstract class, and now this application accepts a non-abstract class:classMyNextClassextendsOtherMixin(Bar){// Required to implement otherMethod here, and the barMethod is inherited.// ...}

Note that last example in particular accepts both abstract and non-abstract base classes (I didn't show an example of CoolMixin accepting a non-abstract class but that should work too).

I believe that's what we should aim for in TypeScript, otherwise we're not effectively modeling plain JS in the most useful way.

Mixins are suppose to be mixed-and-matched, but if we can not work with abstract in a flexible way, we won't be able to mix-and-match mixins.

I'm merely a TypeScript end user, but I hope some solution can be found so that mixins are as convenient as they are in plain JS but with structural types in place.

# Conflicts:
#	src/compiler/parser.ts
#	src/compiler/types.ts
@typescript-bottypescript-bot added the For Backlog Bug PRs that fix a backlog bug label Sep 8, 2020
@rbuckton

Copy link
Copy Markdown
ContributorAuthor

@typescript-bot pack this

@typescript-bot

typescript-bot commented Sep 8, 2020

Copy link
Copy Markdown
Contributor

Heya @rbuckton, I've started to run the tarball bundle task on this PR at 2e77e93. You can monitor the build here.

@typescript-bot

Copy link
Copy Markdown
Contributor

Hey @rbuckton, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
"devDependencies": {
"typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/84547/artifacts?artifactName=tgz&fileId=78EEF1EB3E873A239A07C29FA531FBC2DFBC4D34D336DA97287D6ED06314D6F302&fileName=/typescript-4.1.0-insiders.20200908.tgz"
}
}

and then running npm install.

@rbuckton

rbuckton commented Sep 9, 2020

Copy link
Copy Markdown
ContributorAuthor

@trusktr: A new () => T type is assignable to an abstract new () => T type. A mixin definition that uses something like <TBase extends abstract new (...args: any[]) => {}>(base: TBase) { ... } will work for both abstract and non-abstract superclasses:

functionMixin<TBaseextendsabstractnew(...args: any[])=>{}>(base: TBase){abstractclassMextendsbase{}returnM;}abstractclassAbstractBase{abstractm(): number;}classConcreteBase{m(): number{return1;}}// error: C must implement abstract method `m`classCextendsMixin(AbstractBase){}// no errorclassDextendsMixin(ConcreteBase){}

@rbuckton

Copy link
Copy Markdown
ContributorAuthor

Although it should probably be an error for class M extends base, since M should also be marked abstract.

@orta

orta commented Sep 9, 2020

Copy link
Copy Markdown
Contributor

@typescript-bot pack this

@typescript-bot

typescript-bot commented Sep 9, 2020

Copy link
Copy Markdown
Contributor

Heya @orta, I've started to run the tarball bundle task on this PR at 58753bb. You can monitor the build here.

@typescript-bot

typescript-bot commented Sep 9, 2020

Copy link
Copy Markdown
Contributor

Hey @orta, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
"devDependencies": {
"typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/84644/artifacts?artifactName=tgz&fileId=04128FC607D057BF067072C2A50573E681D420F5D2A6C3EB6555980105DFA32802&fileName=/typescript-4.1.0-insiders.20200909.tgz"
}
}

and then running npm install.


There is also a playground for this build.

@DanielRosenwasser

Copy link
Copy Markdown
Member

I think the one thing that I'm still a little bit surprised about is that there's no way to rewrite the following example

abstractclassFoo{abstractfooMethod(): void}functionextendFoo(FooCtor: abstractnew()=>Foo){// Error! Must implement 'fooMethod'returnclassextendsFooCtor{}}

in such a way that Foo is declared as any other object type like an interface.

The first reason I think this is weird is because abstract doesn't get funneled around as any other modifier, and so this gets a little funky as you try to do algebra over the properties.

abstractclassFoo{abstractfooMethod(): void}// Homomorphic mapped type over `Foo`typeCopyFoo={[KinkeyofFoo]: Foo[K]}functionextendFoo(FooCtor: abstractnew()=>CopyFoo){// No error now!returnclassextendsFooCtor{}}

The second is that in order to model this sort of pattern, you always have to declare a class, even if you won't necessarily be extending from that class (and even if nobody is going to be extending from that class). That on its own feels kind of strange in our mostly-structural type system.

But I don't really know the right way to reconcile all of this - would "fixing" these issues mean that interfaces would have to allow abstract members? Would interfaces themselves need to be declared abstract? And how would this work with class/interface merging?

@rbuckton

rbuckton commented Jan 5, 2021

Copy link
Copy Markdown
ContributorAuthor

The issue is that we don't (yet) have a way to represent this type in any other way. #41587 will eventually add syntax that would permit this in type-space:

typeTFoo=typeofabstractclass{constructor();abstractfooMethod(): void;};functionextendFoo(FooCtor: TFoo){ ... }

However, typeof class doesn't solve the issue that abstract new is intended to solve. With typeof class, you still cannot infer the instance type, so there's no version of InstanceType<T> that could use typeof class on its own. However, with abstract constructor types, we can eventually rewrite InstanceType<T> as this:

typeInstanceType<Textendsabstractnew(...args: any)=>any>=Textendsabstractnew(...args: any)=> infer R ? R : any;

With a new InstanceType, we can have a type definition that preserves abstract without introducing a class in the value-space:

typeTFooInst=InstanceType<typeofabstractclass{abstractfooMethod(): void;}>;

In the end we will need both abstract constructor types andtypeof class, but one is not dependent on the other. The main reason to ship abstract constructor types a version ahead of typeof class has to do with our dependency on typescript-eslint. Since typescript-eslint won't understand abstract new until it ships in a stable release, we need abstract new to ship one version prior to changing InstanceType to avoid lint errors in our own codebase. That way, by the time typeof class lands, we'll have a version of InstanceType that can be used in the way mentioned above.

Whether or not we eventually do something else for abstract in regards to interfaces or our type-algebra may be related to this feature, but is essentially out of scope as any decisions we make there are unlikely to impact the behavior of this feature. The only real cross-cutting concern I see would be that we might eventually loosen the restriction that classes extending from abstract new base types must themselves be declared abstract (instead basing it off of whatever new rules we come up with), and that wouldn't be a breaking change.

@rbuckton

Copy link
Copy Markdown
ContributorAuthor

I think the one thing that I'm still a little bit surprised about is that there's no way to rewrite the following example

abstractclassFoo{abstractfooMethod(): void}functionextendFoo(FooCtor: abstractnew()=>Foo){// Error! Must implement 'fooMethod'returnclassextendsFooCtor{}}

in such a way that Foo is declared as any other object type like an interface.

Actually, the example above isn't the problem, as FooCtor isn't generic, so you can return a non-abstract class:

functionextendFoo(FooCtor: abstractnew()=>Foo){// No errorreturnclassextendsFooCtor{fooMethod(): void{}// implements it just fine.}}

While this isn't necessarily sound, it aligns with other inconsistencies in the language, in that we assume Foo is the only instance type we'll create (even if you could pass an assignment compatible definition with more abstract methods). The important bit we discussed in the design meeting in November was in regards to a generic abstract constructor type. When an abstract constructor type comes from the constraint of a generic, we cannot know all of the possible permutations of that generic, therefore we must ensure the result is still marked abstract. That flows through how we handle mixins in the language, such that abstract methods in the base must eventually be implemented:

interfaceFoo{normalMethod(): void;}declarefunctionFooMixin<Ctorextendsabstractnew(...args: any[])=>any>(base: Ctor): Ctor&(abstractnew(...args: any[])=>Foo);classConcrete{}classSubConcreteextendsMixinFoo(Concrete){}// okabstractclassAbstract{abstractfooMethod(): void;}classSubConcreteAbstractextendsFooMixin(Abstract){// I must implement 'fooMethod' because it's marked 'abstract' in the base class.fooMethod(): void{}}

@rbuckton

Copy link
Copy Markdown
ContributorAuthor

[...] Would interfaces themselves need to be declared abstract? [...]

No. We declare a class as abstract because it affects the constructor side. If we had abstract methods on interfaces, you might say the following are essentially the same:

// as a classabstractclassFoo{abstractfooMethod(): void;}// as an interface/constructorinterfaceFoo{abstractfooMethod(): void;}letFoo: abstractnew()=>Foo;

I would imagine that if we did introduce abstract in an interface method, we'd probably want to make new () => Foo an error since you cannot create an instance of an object with abstract methods, in the same way that class Foo { abstract fooMethod(): void; } is an error today.

@rbuckton

Copy link
Copy Markdown
ContributorAuthor

I'll be pushing up a minor change to declaration emit/quick info shortly to handle inferred return types that result in an anonymous type with abstract construct signatures.

For reference, in this case:

// @target: esnext// @declaration: trueinterfaceMixin{mixinMethod(): void;}functionMixin<TBaseextendsabstractnew(...args: any[])=>any>(baseClass: TBase){// must be `abstract` because we cannot know *all* of the possible abstract members that need to be// implemented for this to be concrete.abstractclassMixinClassextendsbaseClassimplementsMixin{mixinMethod(): void{}staticstaticMixinMethod(): void{}}returnMixinClass;}

We currently emit this:

declarefunctionMixin<TBaseextendsabstractnew(...args: any[])=>any>(baseClass: TBase): {new(...args: any[]): {// does not preserve `abstract`[x: string]: any;mixinMethod(): void;};staticMixinMethod(): void;})&TBase;

When we need to emit this instead:

declarefunctionMixin<TBaseextendsabstractnew(...args: any[])=>any>(baseClass: TBase): ((abstractnew(...args: any[])=>{[x: string]: any;mixinMethod(): void;})&{staticMixinMethod(): void;})&TBase;

@rbuckton

Copy link
Copy Markdown
ContributorAuthor

@RyanCavanaugh can you take one more look with the update for declaration emit?

@rbuckton
rbucktonforce-pushed the abstractConstructSignatures branch from d88027d to cfec2caCompareJanuary 7, 2021 22:56
@rbuckton
rbuckton merged commit 0d284e6 into masterJan 8, 2021
@rbuckton
rbuckton deleted the abstractConstructSignatures branch January 8, 2021 02:23
Zzzen pushed a commit to Zzzen/TypeScript that referenced this pull request Jan 16, 2021
* Add support for abstract constructor types
* Add backwards-compatible overloads for creating/updating constructor types
* Reverting use of 'abstract' in lib/es5.d.ts due to eslint issues
* Update baseline due to reverting lib
* Add error for failing to mark an mixin class as abstract
* Fix declaration/quick info for abstract construct signatures
raymondfeng added a commit to loopbackio/loopback-next that referenced this pull request Feb 26, 2021
The Model/Entity being abstract caused compilation errors as TS 4.2
reinforces abstract checks.
https://devblogs.microsoft.com/typescript/announcing-typescript-4-2/microsoft/TypeScript#36392
Signed-off-by: Raymond Feng <enjoyjava@gmail.com>
raymondfeng added a commit to loopbackio/loopback-next that referenced this pull request Feb 27, 2021
The Model/Entity being abstract caused compilation errors as TS 4.2
reinforces abstract checks.
https://devblogs.microsoft.com/typescript/announcing-typescript-4-2/microsoft/TypeScript#36392
Signed-off-by: Raymond Feng <enjoyjava@gmail.com>
raymondfeng added a commit to loopbackio/loopback-next that referenced this pull request Feb 27, 2021
The Model/Entity being abstract caused compilation errors as TS 4.2
reinforces abstract checks.
https://devblogs.microsoft.com/typescript/announcing-typescript-4-2/microsoft/TypeScript#36392
Signed-off-by: Raymond Feng <enjoyjava@gmail.com>
@ITenthusiasm

ITenthusiasm commented Mar 5, 2021

Copy link
Copy Markdown

Sorry to ask, but has this already been released? abstract doesn't seem to be a recognized keyword in my repository.

@MicahZoltu

MicahZoltu commented Mar 24, 2021

Copy link
Copy Markdown

This also updates the definitions for InstanceType and ConstructorParameters to use the abstract modifier

This doesn't appear to actually be present? There is no mention of ConstructorParameters in the PR diff, and both InstanceType and ConstructorParameters don't support abstract classes in the latest version of TS. You can build a custom ConstructorParameters, suggesting that this has been released.

@rbuckton

Copy link
Copy Markdown
ContributorAuthor

This also updates the definitions for InstanceType and ConstructorParameters to use the abstract modifier

This doesn't appear to actually be present? There is no mention of ConstructorParameters in the PR diff, and both InstanceType and ConstructorParameters don't support abstract classes in the latest version of TS. You can build a custom ConstructorParameters, suggesting that this has been released.

They couldn't be added at the same time due to a lag between TS releases and the TypeScript ESLint plugin. They will be added by #43380.

pktippa pushed a commit to pktippa/loopback-next that referenced this pull request May 1, 2021
The Model/Entity being abstract caused compilation errors as TS 4.2
reinforces abstract checks.
https://devblogs.microsoft.com/typescript/announcing-typescript-4-2/microsoft/TypeScript#36392
Signed-off-by: Raymond Feng <enjoyjava@gmail.com>
@jcalz

Copy link
Copy Markdown
Contributor

I was a little surprised that you can't write { abstract new(): Foo } as a synonym for abstract new() => Foo. Not sure if that's documented anywhere, but I figured I'd mention it.

@rbuckton

Copy link
Copy Markdown
ContributorAuthor

It's hinted at a bit here: #36392 (comment), but was something we discussed in a Design Meeting. Adding abstract signatures to an interface (and possibly abstract members as well) was something we didn't want to touch on yet with this feature, as it introduces significantly more complexity. The intent was for the typeof class type to handle this, eventually.

@ntucker

ntucker commented Feb 6, 2023

Copy link
Copy Markdown

The declaration emit seems to not enforce abstract members...

from

typeConstructor=abstractnew(...args: any[])=>{};functionmixin<TBaseextendsConstructor>(Base: TBase){abstractclassMyClassextendsBase{abstractabs(): void;}returnMyClass;}

I get

typeConstructor=abstractnew(...args: any[])=>{};declarefunctionmixin<TBaseextendsConstructor>(Base: TBase): (abstractnew(...args: any[])=>{abs(): void;})&TBase;

But if I use that as an ambient declaration (imagine publishing a package based on this). Then an implementation lacking abs doesn't error:

classBrokenextendsmixin(class{}){field=5;}

Playground (this obviously errors as it's not using ambient)

@lorenzodallavecchia

Copy link
Copy Markdown

I have noticed that abstract constructor types do not accept a class with a protected constructor.
See this playground.

Argument of type 'typeof A2' is not assignable to parameter of type 'abstract new (...args: any[]) => object'.
Cannot assign a 'protected' constructor type to a 'public' constructor type.(2345)

What is the reason for this behavior?
Abstract constructors cannot be called anyway, so their visibility should not matter IMO.

@microsoftmicrosoft locked as resolved and limited conversation to collaborators Oct 21, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Author: TeamExperimentA fork with an experimental idea which might not make it into masterFor Backlog BugPRs that fix a backlog bug

Projects

None yet

13 participants

@rbuckton@ajafff@trusktr@typescript-bot@orta@DanielRosenwasser@ITenthusiasm@MicahZoltu@jcalz@ntucker@lorenzodallavecchia@RyanCavanaugh@sandersn