TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Request: Class Decorator Mutation

Open Gaelan opened this issue 10 years ago • 272 comments
trafficstars

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'

Gaelan avatar Sep 20 '15 06:09 Gaelan

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

g162h3 avatar Sep 21 '15 22:09 g162h3

@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'

red-0ne avatar Oct 28 '15 02:10 red-0ne

+1 for this. Though I know this is hard to implement, and probably harder to reach an agreement on decorator mutation semantics.

HerringtonDarkholme avatar Nov 02 '15 02:11 HerringtonDarkholme

+1

andreas-karlsson avatar Jan 14 '16 22:01 andreas-karlsson

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 avatar Feb 23 '16 21:02 masaeedu

@masaeedu Do you know any workaround to add a static member to the decorated class?

davojan avatar Mar 29 '16 13:03 davojan

@davojan Sure. Here you go:

class A { }
namespace A {
    export let foo = function() { console.log("foo"); }
}
A.foo();

masaeedu avatar Mar 30 '16 22:03 masaeedu

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)

nevir avatar Jun 04 '16 05:06 nevir

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?

joyt avatar Jun 21 '16 19:06 joyt

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

JakeGinnivan avatar Jun 28 '16 01:06 JakeGinnivan

@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 avatar Jun 28 '16 01:06 masaeedu

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

JakeGinnivan avatar Jun 28 '16 02:06 JakeGinnivan

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.

blai avatar Jun 28 '16 16:06 blai

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 avatar Jun 28 '16 17:06 blai

@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 avatar Jun 28 '16 17:06 nevir

@nevir Yep, you are right. Thanks for clarification.

blai avatar Jun 28 '16 17:06 blai

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

blai avatar Jun 28 '16 17:06 blai

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.

seansfkelley avatar Jul 04 '16 18:07 seansfkelley

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 };

alex-bel avatar Jul 13 '16 20:07 alex-bel

Any news on this? This would be super powerful for metaprogramming!

jeffijoe avatar Oct 19 '16 12:10 jeffijoe

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 avatar Nov 11 '16 01:11 HerringtonDarkholme

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

aluanhaddad avatar Dec 01 '16 07:12 aluanhaddad

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?

shlomiassaf avatar Feb 13 '17 14:02 shlomiassaf

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 avatar Feb 15 '17 06:02 codeandcats

@codeandcats your example is the exact same use case I am here for!

jeffijoe avatar Feb 15 '17 07:02 jeffijoe

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?

codeandcats avatar Feb 18 '17 02:02 codeandcats

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.

zajrik avatar Apr 08 '17 08:04 zajrik

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

jeffijoe avatar Apr 25 '17 15:04 jeffijoe

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.

insidewhy avatar Apr 26 '17 08:04 insidewhy

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

  1. They create a new superclass, so that the class you apply them to has a change to override them
  2. They type check now, without any additional features from TypeScript
  3. They work well with type inference - you don't have to type the return value of the mixin function
  4. They work well with static analysis, especially jump-to-definition - Jumping to the implementation of logger takes you to the mixin implementation, not the interface.

justinfagnani avatar Apr 30 '17 00:04 justinfagnani