microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.06k stars 12.37k forks source link

Generic decorators - could they receive some default type arguments? #2607

Open danielearwicker opened 9 years ago

danielearwicker commented 9 years ago

A decorator can receive arguments, by being defined as a function that accepts parameters and then returns a normal decorator function:

function computed(evaluator: (obj: any) => any) {
    return (obj: any, key: string) => {
        Object.defineProperty(obj, key, { 
            get() { return evaluator(obj); }
        });
    };
}

I could use the above example like this:

class C {
    firstName = "Homer";
    lastName = "Simpson";

    @computed(c => c.firstName + " " + c.lastName) 
    fullName: string;
}

Not a realistic example in that it's just another way of defining a property getter, but it's enough to demonstrates the general problem: c is of type any so the access to firstName and lastName is not type-checked, and nor is the result of the expression.

We can attempt to address this manually, because decorators can be generic:

function computed<O, R>(evaluator: (obj: O) => R) {
    return (obj: O, key: string) => {
        Object.defineProperty(obj, key, { 
            get() { return evaluator(obj); }
        });
    };
}

class C {
    firstName = "Homer";
    lastName = "Simpson";

    @computed<C, string>(c => c.firstName + " " + c.lastName)
    fullName: string;
}

But this still doesn't close the loop. The compiler is happy with the following abuse, in which c is supposedly a string but is actually a C, and fullName is declared a string but will actually become a number (albeit NaN I guess):

@computed<string, number>(c => parseInt(c, 10))
fullName: string;

In summary: decorators are passed the values of the meta-information in standard parameters (target, key, value), but they are not passed the compile-time types of those values in a way that can be used to create fully type-safe decorators.

So it would be helpful if in decorator-defining functions with generic parameters, those parameters could somehow map automatically to the types of target and value (as given meaning in the __decorate helper).

For big yucks, a straw man in which the compiler recognises a couple of built-in decorators that can appear on the type parameters:

function computed<@decoratorTarget O, @decoratorValue R>(evaluator: (obj: O) => R) {
    ...
}

Being decorated in this way, it would not be possible to manually specify type arguments for those type parameters when using the decorator. They would effectively be invisible to the user. If the decorator has its own custom type parameters, they would appear from the outside to be the only parameters (and for clarity should be at the front of the list).

RyanCavanaugh commented 8 years ago

@rbuckton can you boil this down to something non-decorator-experts can understand? :confused:

DanielRosenwasser commented 8 years ago

Basically the idea is that you have a generic decorator.

function blah<O>(x: O, y: string) {
    // ...
}

class C {
    firstName = "Homer";

    @blah
    fullName: string;
}

That's all fine and dandy, but it doesn't really do much for you. There's nothing you can do with O. So you instead take a callback:

function blah<O>(f: (obj: O) => any) {
    // ...
}

class C {
    firstName = "Homer";

    @blah<C>(c => "I am evil " + c.firstName + ".")
    fullName: string;
}

Now you're able to decorate using a callback that is aware of the shape of the prototype being decorated. So it would be valid to write c.firstName as above, but not c.age, which is desirable here.

So this works, but it's a little annoying because you need to specify C as a type argument. @danielearwicker wants type inference here from the class prototype.


The problem is that this is akin to the following:

let f: <T>(callback: (x: T) => void) => (y: T) => void;

// This errors on 'x.a', since 'T' is inferred as '{}'
f(x => x.a)({ a: 100 });

The issue is we only make inferences once at the invocation site of a generic function. I'm not sure what we would do here to fix this.

WanderWang commented 8 years ago

did this issue have any update ? I have the same problem about this

nicholasguyett commented 7 years ago

@RyanCavanaugh, as you seem to be the first line of defense in the issues, I wanted to ping this suggestion back to your attention. It hasn't seemed to have made it into your more recent suggestion backlog slogs and I think it would shore up a major gap in typesafety for Typescript decorators.

RyanCavanaugh commented 7 years ago

@nicholasguyett I'll try to get this on the next one, but the office will be pretty empty for the next two weeks or so. Feel free to ping me again - thanks!

Strate commented 7 years ago

It would also be nice to infer property type from decorator usage, for example:

class SomeClass {
  @initialize(() => 321) // function return number, it is expected to be type of property
  private prop
}
sberan commented 7 years ago

@RyanCavanaugh is this open for pull requests? This feature would be amazing for a project I'm working on and would be interested in contributing if possible.

cntech commented 6 years ago

Missing type safety is the main reason I advice against using decorators wherever possible. My projects are pretty type-safe (at compile time) and I do not want to undermine that type-safety by using decorators.

Thus I am very interested in this feature.

As far as I understand, it would also make possible a type-safe lazy value decorator in the following form:

@lazy(() => "the lazy value")
readonly someLazyValue: string

It would link the type of the return value "the lazy value" to the type of the property (string). Which is currently not possible. Please correct me if I am wrong.

danielearwicker commented 6 years ago

@cntech for that example, have you considered:

@lazy get someLazyValue() {
    return "the lazy value"
}

The decorator would just wrap the getter function, replacing it with an on-demand cache initialiser. The type signature is already right, so no need for extra features in that case.

This is the easiest way to implement my original example above - and is exactly what libraries like MobX do for computed.

cntech commented 6 years ago

I have considered that but I somehow wanted to get rid of the getter syntax with get and return. But you are right, it is an option to write it that way.

Still I feel very limited in freedom if decorators are not 100% type-safe which is why I long for that feature. My lazy value was just an illustration.

danielearwicker commented 6 years ago

I guess I wasn't very clear. Here's how it works:

export function lazy(target: any, key: string, descriptor: PropertyDescriptor) {
  const getter = descriptor.get!;
  let cache: { value: any } | undefined;
  descriptor.get = () => (cache = cache || { value: getter() }).value;
}

The getter from the original property definition is stored away. Then replaced in the descriptor with a new version that tries the cache first. Give it a try. It is easy to see it working:

let counter = 1;

class Test {
  @lazy get something() {
    return `counter is ${counter}`;
  }
}

const test = new Test();

counter++;
console.log(test.something); // logs "counter is 2"
counter++;
console.log(test.something); // still logs "counter is 2"
Ixonal commented 6 years ago

So, I just ran into this when messing around with validation code. I'm applying validation rules with decorators, and it's fine for most cases, but I also have a custom rule for special cases that allows you to write the custom logic right there.

export class Foo {
  @validate<string>(val => val.startsWith("foo"), "Must start with 'foo'")
  public fooProp: string = "foo";
}
huafu commented 6 years ago

What about type-safety/inference for this kind of situation?

const decorator = (t, k, d) => ({ get () { return this._data[k] } })

type Dummy = { bar: string }
class Foo {
  private _data: Dummy
  // how to define `decorator()` so that `bar` will infer `string` type
  // without setting its type here?
  @decorator bar
}

new Foo().bar // `any`, but `string` wanted
waterplea commented 5 years ago

Any news on that, guys? In my project I already have a couple of decorators that take this in form of context, just like the first example here, and if they could infer type from the class they are used in, it would be great.

artembatura commented 5 years ago

@DanielRosenwasser Your basic idea of manually passing type works, but there big disadvantage

const f = <T, TKey extends keyof T = keyof T>(
  object: T,
  key: TKey
): T[TKey] => {
  return object[key];
};

// without manually passing type
f({ a: true, b: false }, 'a');

// with manually passing type
f<{
  a: boolean;
  b: boolean;
}>({ a: true, b: false }, 'a');

When we pass type manually our TKey isn't resolved as in example without manually passing type: Good image Bad image

christopher-kiss commented 5 years ago

@RyanCavanaugh,

I can see that this made it into two discussion meetings, however ran out of time to discuss both times. Any chance for this to make it onto another?

I'm sure I don't need to explain the value of decorators, so getting better typing support for them would help see more widespread use.

I'm also aware that implementing the updating ES spec for decorators is on the road map, does that implementation include better typing support such as noted in this ticket, or are the different spaces?

Would love to see an update on this topic, as I've been hitting these roadblocks, as I'm sure others have been, which hinders the dev experience of using and implementing decorators.

infloop commented 4 years ago

Any update on that?

jcalz commented 3 years ago

Cross-referencing to #39903