Repository navigation
Proposal: Allow a name bound to a class value to be used in a type position to reference the class's instance type #36348
Description
Proposal: Allow a name bound to a class value to be used in a type position to reference the class's instance type
What?
In TypeScript, when a class is created and bound to a name via a class declaration, the name can be used in both value and type positions. In contrast, when a class is bound to a name via const, let, or var, the name can only be used in value positions.
I propose that when a class is bound to a name via const, let, or var, the name should be usable in both value and type positions, and that when such a name is used in a type position, it be interpreted as the instance type of the bound class value.
More formally:
- A name should be deemed a ClassValueName if:
- It is declared via a
const,let, orvarstatement, and - Its type is assignable to
new (...args: any[]) => any, and - Its type is not
any
- It is declared via a
- A ClassValueName should be usable in any type position that a class declaration name could be used in, and
- A ClassValueName used in a type position should be interpreted as the instance type of the class value, in the same way that a class declaration name used in a type position is interpreted as the instance type of the class declaration
Examples of proposed behaviour
// `Foo` is of type `new () => {}`
const Foo = class {};
// `Foo` can be used in a type position here, and `foo` is of type `{}`
const foo: Foo = new Foo();// `Foo` is of type `new <T>(value: T) => { value: T }`
const Foo = class <T> { constructor(public value: T) {} };
// `Foo` can be used in a type position here (and is generic), and `foo` is of type `{ value: number }`
const foo: Foo<number> = new Foo(42);function newFooClass() {
return class <T> { constructor(public value: T) {} };
}
// `Foo` is of type `new <T>(value: T) => { value: T }`
const Foo = newFooClass();
const foo: Foo<number> = new Foo(42);const classes = {
Foo: class <T> { constructor(public value: T) {} }
};
// `Foo` is of type `new <T>(value: T) => { value: T }`
const { Foo } = classes;
const foo: Foo<number> = new Foo(42);const withBrand =
<B extends string>(brand: B) =>
<C extends new (...args: any[]) => {}>(ctor: C) =>
class extends ctor {
brand: B = brand;
};
const Foo = class <T> { constructor(public value: T) {} };
// `FooWithBrand` is of type `new <T>(value: T) => ({ value: T } & { brand: 'Foo' })`
const FooWithBrand = withBrand('Foo')(Foo);
// `FooWithBrand` can be used in a type position here (and is generic), and `fooWithBrand` is of type `{ value: number } & { brand: 'Foo' }`
const fooWithBrand: FooWithBrand<number> = new FooWithBrand(42);Why?
Unlike a class declaration, a class value requires a separate type declaration to expose the class's instance type
For class declarations, we can simply use the name of the class in a type position to reference its instance type:
class Foo {}
const foo: Foo = new Foo();But for class values, a separate type declaration is required:
const Foo = class {};
type Foo = InstanceType<typeof Foo>;
const foo: Foo = new Foo();Requiring a separate type declaration has a few issues:
- It's inconsistent with class declarations (which don't require a manual type declaration)
- It doesn't work for generic classes (see next section)
- It's boilerplate (which adds up, especially with multiple classes in the same file)
With this proposal however, we wouldn't need a separate type declaration, and all of the following would just work:
const Foo = class {};
const foo: Foo = new Foo();function newFooClass() { return class {}; }
const Foo = newFooClass();
const foo: Foo = new Foo();const classes = { Foo: class {} };
const { Foo } = classes;
const foo: Foo = new Foo();There is currently no way to access the generic instance type of a generic class value
None of the following work:
const Foo = class <T> { constructor(public value: T) {} };
const foo: Foo<number> = new Foo(42);
// => Error: 'Foo' refers to a value, but is being used as a type here.const Foo = class <T> { constructor(public value: T) {} };
type Foo = InstanceType<typeof Foo>;
const foo: Foo<number> = new Foo(42);
// => Error: Type 'Foo' is not generic.const Foo = class <T> { constructor(public value: T) {} };
type Foo<T> = InstanceType<typeof Foo<T>>;
// => Error: '>' expected.
const foo: Foo<number> = new Foo(42);const Foo = class <T> { constructor(public value: T) {} };
type Foo<T> = InstanceType<typeof Foo><T>;
// => Error: ';' expected.
const foo: Foo<number> = new Foo(42);With this proposal however, we could simply use the name of the class value in a type position to reference its generic instance type:
const Foo = class <T> { constructor(public value: T) {} };
const foo: Foo<number> = new Foo(42);
// => No errorIt enables a potential workaround for #4881
This doesn't work:
const withBrand =
<B extends string>(brand: B) =>
<C extends new (...args: any[]) => {}>(ctor: C) =>
class extends ctor {
brand: B = brand;
};
@withBrand('Foo')
class FooWithBrand<T> {
constructor(readonly value: T) {}
}
type FooBrand<T> = FooWithBrand<T>['brand'];
// => Error: Property 'brand' does not exist on type 'FooWithBrand<T>'.But with this proposal, we could do this:
const withBrand =
<B extends string>(brand: B) =>
<C extends new (...args: any[]) => {}>(ctor: C) =>
class extends ctor {
brand: B = brand;
};
const FooWithBrand = withBrand('Foo')(
class <T> {
constructor(public value: T) {}
}
);
type FooBrand<T> = FooWithBrand<T>['brand'];While this wouldn't be type support for actual decorators, it would at least provide a means of class decoration that's reflected at the type level.
Flow supports this (there is prior art)
The following all work in Flow:
const Foo = class {};
const foo: Foo = new Foo();const Foo = class <T> {
value: T;
constructor(value: T) { this.value = value; }
};
const foo: Foo<number> = new Foo(12);function newFooClass() { return class {}; }
const Foo = newFooClass();
const foo: Foo = new Foo();function newFooClass() {
return class <T> {
value: T;
constructor(value: T) { this.value = value; }
};
}
const Foo = newFooClass();
const foo: Foo<number> = new Foo(42);const classes = { Foo: class {} };
const { Foo } = classes;
const foo: Foo = new Foo();const classes = {
Foo: class <T> {
value: T;
constructor(value: T) { this.value = value; }
}
};
const { Foo } = classes;
const foo: Foo<number> = new Foo(42);While feature parity with Flow is obviously not one of TypeScript's goals, Flow supporting this behaviour means that there's at least a precedent for it.
Why not?
It may be a breaking change
Depending on the implementation approach, this may be a breaking change.
For example, if this proposal were to be implemented by having the compiler automagically generate a separate type name whenever it encountered a ClassValueName, the generated type name may clash with an existing type name and break that currently working code.
On the other hand, if it were possible to implement this proposal using some kind of fallback mechanism (such as preferring any existing type name over using a ClassValueName in a type position, for instance), then the change would be backwards compatible.
It is currently unclear whether or not the proposed change can be implemented in a backwards compatible manner.
It "muddies" the separation between the value and type namespaces
TypeScript maintains separate namespaces for values and types. While this separation is fairly core to the language, there are already several exceptions in the form of class declarations, enums, and namespaces.
This proposal would introduce a new exception to that separation. This exception is particularly notable, as it would mark the first time a const, let, or var-declared name would be permitted in a type position. This is in contrast to all current exceptions, which each have construct-specific declaration syntax that differentiates them from standard variable declarations.
This proposal's "muddying" of the separation between the value and type namespaces may be confusing and/or surprising for both new and existing TypeScript users.
Next steps
- Get feedback from both the TypeScript team and the community
- Investigate potential implementation strategies
Checklist
My suggestion meets these guidelines:
- This wouldn't be a breaking change in existing TypeScript/JavaScript code (unclear at this stage)
- 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.
Activity
RyanCavanaugh commentedon Jan 23, 2020
This all makes sense on the surface but I'm struggling to find the real use cases. Any time you write
const Foo = class {with the class expression bare, you could have just writtenclass Foo {without any loss of generality AFAICT. So presumably you're actually writing something likeconst Foo = func(class { ..., at which point this logic wouldn't kick in.What are some reasons to write
const Foo = class {overclass Foo {?treybrisbane commentedon Jan 25, 2020
To answer your question, here are a couple of reasons I find the
const Foo = class ...syntax preferrable:I agree that for bare class expressions specifically, you could in many cases simply opt for
class Foo ...overconst Foo = class ...style, however the bare class expression case is only a small part of what would be enabled by this proposal. In fact, one of the main motivations for this proposal is the very class decoration case you mentioned:Perhaps I didn't make it clear enough (apologies if so), but the intent is very much to make that possible. 🙂
TypeScript's current decorator syntax implementation doesn't support type-level changes to decorated classes, and given how radically the TC39 decorator proposal has diverged, I don't expect you guys (the TypeScript team) to be willing to change that. That's why one of the main motivations is class decoration; to serve as a workaround without encouraging further use of
experimentalDecorators. 🙂ferk6a commentedon Feb 19, 2020
Aah, so this is what biting me with my mixins!
I have the following code (Playground):
And
yis invalid because of this.A possible solution for me is doing this:
But that seems a bit over the top...
My current solution currently involves rewriting the code with a monkey-patching pattern instead (Playground):
It's kinda bad because I have to handle
superand prototype problems, but I guess it looks nicer to use.trusktr commentedon Aug 9, 2020
I think this would definitely help with mixins.
treybrisbane commentedon Nov 13, 2020
Ryan Cavanaugh (@RyanCavanaugh) This would obviously be a fairly niche feature, and one that would be difficult for the team to prioritise as a result. With that said, would the team be willing to accept a community PR for this?
RyanCavanaugh commentedon Nov 13, 2020
Trey Brisbane (@treybrisbane) the presence/absence of a PR doesn't change our feature acceptance criteria in either direction. This would be straightforward to implement; the question is rather whether or not it's a good idea for the language to behave this way.
Looking at this again with fresh eyes, the bar is very high since it would effectively mean that adding any new type name to the global scope would start being a potential breaking change, since we would have to make global type names "win" over local value names for this change to itself not be an enormous breaking change.
RyanCavanaugh commentedon Nov 13, 2020
It's especially problematic in that regard since there's really no way of predicting what global type names are going to come into your program through
.d.tsfiles and their dependencies. We'd have to start applying a much stricter regime on DefinitelyTyped to enforce people to not create new global type names unless they absolutely had to. It'd be weird from a user perspective -- you upgrade some type defs from@typesand a new global shows up and starts shadowing your local value-as-type name (since it has to), and there'd no straightforward way to work around that except for to apply the existing workaround of declaring a local type alias.treybrisbane commentedon Nov 13, 2020
Ryan Cavanaugh (@RyanCavanaugh) Thanks for taking the time to look at this again. 🙂
You raise some very good points. With them in mind, I agree with your assessment that this is a pretty tough sell.
I kinda wanna leave the issue open just on the off chance that it gains popularity and/or somehow becomes more feasible later, however given the implications of the change, I would totally understand if you want to decline and close it now. I'll leave that up to you to decide.
Again, thanks for taking the time to look at this and write out your thoughts. 🙂
Remove global types from `apollo-server-env`
Remove global types from `apollo-server-env`
Upgrade TypeScript and typecheck tests
Upgrade TypeScript and typecheck tests
Upgrade TypeScript and typecheck tests
Upgrade TypeScript and typecheck tests (#5165)
Upgrade TypeScript and typecheck tests (#5165)
lkj4 commentedon Feb 20, 2022
Ryan Cavanaugh (@RyanCavanaugh) you need this if you use mixins
I use mixins a lot—composition is better than inheritance most of the times—and cannot use them as types anymore? At the end of the day, they are perfectly fine classes but they can't be used as types?
So, this is not just an edge use case but it took me also half a day to understand what's wrong with my code. First, I had to refactor half a day to understand that mixins are the culprit and then, I had to find this thread to give the confusion a name. I'd say next to some missing essential feature we add some unintuitive behavior which adds a lot confusion and a so-so dev experience to the mix. Using classes as types/interfaces is one of the killer features of TS.
Check the last two lines for an example and to see the difference: https://stackblitz.com/edit/typescript-1czz7l?file=index.ts