TypeScript
TypeScript copied to clipboard
Request: Class Decorator Mutation
If we can get this to type check properly, we would have perfect support for boilerplate-free mixins:
declare function Blah<T>(target: T): T & {foo: number}
@Blah
class Foo {
bar() {
return this.foo; // Property 'foo' does not exist on type 'Foo'
}
}
new Foo().foo; // Property 'foo' does not exist on type 'Foo'
Same would be useful for methods:
class Foo {
@async
bar(x: number) {
return x || Promise.resolve(...);
}
}
The async decorator is supposed to change the return type to Promise<any>.
@Gaelan, this is exactly what we are needing here! It would make mixins just natural to work with.
class asPersistent {
id: number;
version: number;
sync(): Promise<DriverResponse> { ... }
...
}
function PersistThrough<T>(driver: { new(): Driver }): (t: T) => T & asPersistent {
return (target: T): T & asPersistent {
Persistent.call(target.prototype, driver);
return target;
}
}
@PersistThrough(MyDBDriver)
Article extends TextNode {
title: string;
}
var article = new Article();
article.title = 'blah';
article.sync() // Property 'sync' does not exist on type 'Article'
+1 for this. Though I know this is hard to implement, and probably harder to reach an agreement on decorator mutation semantics.
+1
If the primary benefit of this is introducing additional members to the type signature, you can already do that with interface merging:
interface Foo { foo(): number }
class Foo {
bar() {
return this.foo();
}
}
Foo.prototype.foo = function() { return 10; }
new Foo().foo();
If the decorator is an actual function that the compiler needs to invoke in order to imperatively mutate the class, this doesn't seem like an idiomatic thing to do in a type safe language, IMHO.
@masaeedu Do you know any workaround to add a static member to the decorated class?
@davojan Sure. Here you go:
class A { }
namespace A {
export let foo = function() { console.log("foo"); }
}
A.foo();
It would also be useful to be able to introduce multiple properties to a class when decorating a method (for example, a helper that generates an associated setter for a getter, or something along those lines)
The react-redux typings for connect takes a component and returns a modified component whose props don't include the connected props received through redux, but it seems TS doesn't recognize their connect definition as a decorator due to this issue. Does anyone have a workaround?
I think the ClassDecorator type definition needs changing.
Currently it's declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;. Maybe it could be changed to
declare type MutatingClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type WrappingClassDecorator = <TFunction extends Function, TDecoratorFunction extends Function>(target: TFunction) => TDecoratorFunction;
declare type ClassDecorator = MutatingClassDecorator | WrappingClassDecorator;
Obviously the naming sucks and I have no idea if this sort of thing will work (I am just trying to convert a Babel app over to typescript and am hitting this).
@joyt Could you provide a playground reconstruction of the problem? I don't use react-redux, but as I've mentioned before, I think any extensions you desire to the shape of a type can be declared using interface merging.
@masaeedu here is a basic breakdown of the moving parts..
Basically the decorator provides a bunch of the props to the React component, so the generic type of the decorator is a subset of the decorated component, not a superset.
Not sure if this is helpful, but tried to put together a non-runnable sample to show you the types in play.
// React types
class Component<TProps> {
props: TProps
}
class ComponentClass<TProps> {
}
interface ComponentDecorator<TOriginalProps, TOwnProps> {
(component: ComponentClass<TOriginalProps>): ComponentClass<TOwnProps>;
}
// Redux types
interface MapStateToProps<TStateProps, TOwnProps> {
(state: any, ownProps?: TOwnProps): TStateProps;
}
// Fake react create class
function createClass(component: any, props: any): any {
}
// Connect wraps the decorated component, providing a bunch of the properies
// So we want to return a ComponentDecorator which exposes LESS than
// the original component
function connect<TStateProps, TOwnProps>(
mapStateToProps: MapStateToProps<TStateProps, TOwnProps>
): ComponentDecorator<TStateProps, TOwnProps> {
return (ComponentClass) => {
let mappedState = mapStateToProps({
bar: 'bar value'
})
class Wrapped {
render() {
return createClass(ComponentClass, mappedState)
}
}
return Wrapped
}
}
// App Types
interface AllProps {
foo: string
bar: string
}
interface OwnProps {
bar: string
}
// This does not work...
// @connect<AllProps, OwnProps>(state => state.foo)
// export default class MyComponent extends Component<AllProps> {
// }
// This does
class MyComponent extends Component<AllProps> {
}
export default connect<AllProps, OwnProps>(state => state.foo)(MyComponent)
//The type exported should be ComponentClass<OwnProps>,
// currently the decorator means we have to export ComponentClass<AllProps>
If you want a full working example I suggest pulling down https://github.com/jaysoo/todomvc-redux-react-typescript or another sample react/redux/typescript project.
According to https://github.com/wycats/javascript-decorators#class-declaration, my understanding is that the proposed declare type WrappingClassDecorator = <TFunction extends Function, TDecoratorFunction extends Function>(target: TFunction) => TDecoratorFunction; is invalid.
The spec says:
@F("color")
@G
class Foo {
}
is translate to:
var Foo = (function () {
class Foo {
}
Foo = F("color")(Foo = G(Foo) || Foo) || Foo;
return Foo;
})();
So if I understand it correctly, the following should be true:
declare function F<T>(target: T): void;
@F
class Foo {}
let a: Foo = new Foo(); // valid
class X {}
declare function F<T>(target: T): X;
@F
class Foo {}
let a: X = new Foo(); // valid
let b: Foo = new Foo(); // INVALID
declare function F<T>(target: T): void;
declare function G<T>(target: T): void;
@F
@G
class Foo {}
let a: Foo = new Foo(); // valid
class X {}
declare function F<T>(target: T): void;
declare function G<T>(target: T): X;
@F
@G
class Foo {}
@G
class Bar {}
@F
class Baz {}
let a: Foo = new Foo(); // valid
let b: X = new Foo(); // INVALID
let c: X = new Bar(); // valid
let d: Bar = new Bar(); // INVALID
let e: Baz = new Baz(); // valid
class X {}
declare function F<T>(target: T): X;
declare function G<T>(target: T): void;
@F
@G
class Foo {}
@G
class Bar {}
@F
class Baz {}
let a: X = new Foo(); // valid
let b: Bar = new Bar(); // valid
let c: X = new Baz(); // valid
let d: Baz = new Baz(); // INVALID
@blai
For your example:
class X {} declare function F<T>(target: T): X; @F class Foo {} let a: X = new Foo(); // valid let b: Foo = new Foo(); // INVALID
I'm assuming you mean that F returns a class that conforms to X (and is not an instance of X)? E.g:
declare function F<T>(target: T): typeof X;
For that case, the assertions should be:
let a: X = new Foo(); // valid
let b: Foo = new Foo(); // valid
The Foo that is in scope of those let statements has been mutated by the decorator. The original Foo is no longer reachable. It's effectively equivalent to:
let Foo = F(class Foo {});
@nevir Yep, you are right. Thanks for clarification.
On a side note, it seems like turning off the check to invalidate mutated class types is relatively easy:
diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts
index 06591a7..2320aff 100644
--- a/src/compiler/checker.ts
+++ b/src/compiler/checker.ts
@@ -11584,10 +11584,6 @@ namespace ts {
*/
function getDiagnosticHeadMessageForDecoratorResolution(node: Decorator) {
switch (node.parent.kind) {
- case SyntaxKind.ClassDeclaration:
- case SyntaxKind.ClassExpression:
- return Diagnostics.Unable_to_resolve_signature_of_class_decorator_when_called_as_an_expression;
-
case SyntaxKind.Parameter:
return Diagnostics.Unable_to_resolve_signature_of_parameter_decorator_when_called_as_an_expression;
}
/** Check a decorator */
function checkDecorator(node: Decorator): void {
const signature = getResolvedSignature(node);
const returnType = getReturnTypeOfSignature(signature);
if (returnType.flags & TypeFlags.Any) {
@@ -14295,9 +14291,7 @@ namespace ts {
let errorInfo: DiagnosticMessageChain;
switch (node.parent.kind) {
case SyntaxKind.ClassDeclaration:
- const classSymbol = getSymbolOfNode(node.parent);
- const classConstructorType = getTypeOfSymbol(classSymbol);
- expectedReturnType = getUnionType([classConstructorType, voidType]);
+ expectedReturnType = returnType;
break;
case SyntaxKind.Parameter:
}
But I am not knowledgable enough to make the compiler output the correct type definitions of the mutated class. I have the following test:
tests/cases/conformance/decorators/class/decoratorOnClass10.ts
// @target:es5
// @experimentaldecorators: true
class X {}
class Y {}
declare function dec1<T>(target: T): T | typeof X;
declare function dec2<T>(target: T): typeof Y;
@dec1
@dec2
export default class C {
}
var c1: X | Y = new C();
var c2: X = new C();
var c3: Y = new C();
It generates tests/baselines/local/decoratorOnClass10.types
=== tests/cases/conformance/decorators/class/decoratorOnClass10.ts ===
class X {}
>X : X
class Y {}
>Y : Y
declare function dec1<T>(target: T): T | typeof X;
>dec1 : <T>(target: T) => T | typeof X
>T : T
>target : T
>T : T
>T : T
>X : typeof X
declare function dec2<T>(target: T): typeof Y;
>dec2 : <T>(target: T) => typeof Y
>T : T
>target : T
>T : T
>Y : typeof Y
@dec1
>dec1 : <T>(target: T) => T | typeof X
@dec2
>dec2 : <T>(target: T) => typeof Y
export default class C {
>C : C
}
var c1: X | Y = new C();
>c1 : X | Y
>X : X
>Y : Y
>new C() : C
>C : typeof C
var c2: X = new C();
>c2 : X
>X : X
>new C() : C
>C : typeof C
var c3: Y = new C();
>c3 : Y
>Y : Y
>new C() : C
>C : typeof C
I was expecting
>C: typeof C to be >C: typeof X | typeof Y
For those interested in react-redux's connect as a case study for this feature, I've filed https://github.com/DefinitelyTyped/DefinitelyTyped/issues/9951 to track the issue in one place.
I've read all comments on this issue and got an idea that decorator's signature doesn't actually shows what it can do with wrapped class.
Consider this one:
function decorator(target) {
target.prototype.someNewMethod = function() { ... };
return new Wrapper(target);
}
It should be typed in that way:
declare function decorator<T>(target: T): Wrapper<T>;
But this signature doesn't tell us that decorator has added new things to the target's prototype.
On the other hand, this one doesn't tell us that decorator has actually returned a wrapper:
declare function decorator<T>(target: T): T & { someMethod: () => void };
Any news on this? This would be super powerful for metaprogramming!
What about a simpler approach to this problem? For a decorated class, we bind the class name to the decorator return value, as a syntactic sugar.
declare function Blah<T>(target: T): T & {foo: number}
@Blah
class Foo {
bar() {
return this.foo; // Property 'foo' does not exist on type 'Foo'
}
}
// is desugared to
const Foo = Blah(class Foo {
// this.foo is not available here
})
new Foo.foo // foo is available here.
Implementation-wise, this will introduce one synthetic symbol for decorated class. And the original class name is only bound to class body scope.
@HerringtonDarkholme I think that would be a nicely pragmatic approach that would provide most of the expressiveness desired. Great Idea!
I definitely want to see this someday
I often write a class for Angular 2 or for Aurelia, that looks like this:
import {Http} from 'aurelia-fetch-client';
import {User} from 'models';
// accesses backend routes for 'api/user'
@autoinject export default class UserService {
constructor(readonly http : Http) { }
readonly resourceUrl = 'api/users';
async get(id: number) {
const response = await this.http.fetch(this.resourceUrl);
const user = await response.json() as User;
return user;
}
async post(id: number, model: { [K in keyof User]?: User[K] }) {
const response = await this.http.post(`${this.resourceUrl}/`${id}`, model);
return await response.json();
}
}
What I want to write is something like decorators/api-client.ts
import {Http} from 'aurelia-fetch-client';
export type Target = { name; new (...args): { http: Http }};
export default function apiClient<T extends { id: string }>(resourceUrl: string) {
return (target: Target) => {
type AugmentedTarget = Target & { get(id: number): Promise<T>, post(id, model: Partial<T>) };
const t = target as AugmentedTarget;
t.prototype.get = async function (id: number) {
const response = await this.http.fetch(resourceUrl);
return await response.json() as T;
}
}
}
and then I could generically apply it like
import {Http} from 'aurelia-fetch-client';
import apiClient from ./decorators/api-client
import {User} from 'models';
@apiClient<User>('api/users') export default class UserService {
constructor(readonly http : Http) { }
}
with no loss of typesafety. This would be a boon for writing clean, expressive code.
Reviving this issue.
Now that #13743 is out and mixin support is in the language this is a super useful feature.
@HerringtonDarkholme is less suitable for this case though, having to declare the return type of the decorator looses some dynamic features...
@ahejlsberg, @mhegazy Do you think this is doable?
I have another usage scenario I'm not sure is yet covered by this conversation but probably falls under the same umbrella.
I would like to implement a method decorator that changes the type of the method entirely (not the return type or parameters but the entire function). e.g.
type AsyncTask<Method extends Function> = {
isRunning(): boolean;
} & Method;
// Decorator definition...
function asyncTask(target, methodName, descriptor) {
...
}
class Order {
@asyncTask
async save(): Promise<void> {
// Performs an async task and returns a promise
...
}
}
const order = new Order();
order.save();
order.save.isRunning(); // Returns true
Totally possible in JavaScript, that's not the problem obviously, but in TypeScript I need the asyncTask decorator to change the type of the decorated method from () => Promise<void> to AsyncTask<() => Promise<void>>.
Pretty sure this isn't possible now and probably falls under the umbrella of this issue?
@codeandcats your example is the exact same use case I am here for!
Hi @ohjames, forgive me, I'm having trouble groking your example, any chance you could rewrite into something that works as is in the playground?
Any progress on this? I had this in my head all day, unaware of this issue, went to go implement it only to find out that the compiler doesn't pick up on it. I have a project that could use a better logging solution so I wrote a quick singleton to later expand into a full-fledged logger that I was going to attach to classes via a decorator like
@loggable
class Foo { }
and I wrote the necessary code for it
type Loggable<T> = T & { logger: Logger };
function loggable<T extends Function>(target: T): Loggable<T>
{
Object.defineProperty(target.prototype, 'logger',
{ value: Logger.instance() });
return <Loggable<T>> target;
}
and the logger property is definitely present at runtime but regrettably not picked up by the compiler.
I would love to see some resolution to this issue, especially since a runtime construct like this should absolutely be able to be properly represented at compile-time.
I ended up settling for a property decorator just to get me by for now:
function logger<T>(target: T, key: string): void
{
Object.defineProperty(target, 'logger',
{ value: Logger.instance() });
}
and attaching it to classes like
class Foo {
@logger private logger: Logger;
...
but this is far more boilerplate per class utilizing the logger than a simple @loggable class decorator. I suppose I could feasibly typecast like (this as Loggable<this>).logger but this is also pretty far from ideal, especially after doing it a handful of times. It'd get tiresome very quickly.
I had to can TS for an entire app mainly cause I was unable to get https://github.com/jeffijoe/mobx-task working with decorators. I hope this will be addressed soon. 😄
It's very irritating in the Angular 2 ecosystem where decorators and TypeScript are treated as first class citizens. Yet the minute you try to add a property with a decorator the TypeScript compiler says no. I would have thought the Angular 2 team would show some interest in this issue.
@zajrik you can accomplish what you want with class mixins that have been supported with proper typing since TS 2.2:
Define your Loggable mixin like so:
type Constructor<T> = new(...args: any[]) => T;
interface Logger {}
// You don't strictly need this interface, type inference will determine the shape of Loggable,
// you only need it if you want to refer to Loggable in a type position.
interface Loggable {
logger: Logger;
}
function Loggable<T extends Constructor<object>>(superclass: T) {
return class extends superclass {
logger: Logger;
};
}
and then you can use it in a few ways. Either in the extends clause of a class declaration:
class Foo {
superProperty: string;
}
class LoggableFoo extends Loggable(Foo) {
subProperty: number;
}
TS knows that instances of LoggableFoo have superProperty, logger, and subProperty:
const o = new LoggableFoo();
o.superProperty; // string
o.logger; // Logger
o.subProperty; // number
You can also use a mixin as an expression that returns the concrete class you want to use:
const LoggableFoo = Loggable(Foo);
You can also use a class mixin as a decorator, but it has some slightly different semantics, mainly that is subclasses your class, rather than allowing your class to subclass it.
Class mixins have several advantages over decorators, IMO:
- They create a new superclass, so that the class you apply them to has a change to override them
- They type check now, without any additional features from TypeScript
- They work well with type inference - you don't have to type the return value of the mixin function
- They work well with static analysis, especially jump-to-definition - Jumping to the implementation of
loggertakes you to the mixin implementation, not the interface.