Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Typed 'this' in object literal methods #14141

Merged
merged 28 commits into from
Mar 6, 2017
Merged

Typed 'this' in object literal methods #14141

merged 28 commits into from
Mar 6, 2017

Conversation

ahejlsberg
Copy link
Member

@ahejlsberg ahejlsberg commented Feb 17, 2017

With this PR we strongly type this in methods of object literals and provide a facility for controlling the type of this through contextual typing. The new behavior is only enabled in --noImplicitThis mode.

The type of the expression this in a method of an object literal is now determined as follows:

  • If the method has an explicitly declared this parameter, this has the type of that parameter.
  • Otherwise, if the method is contextually typed by a signature with a this parameter, this has the type of that parameter.
  • Otherwise, if --noImplicitThis is enabled and the containing object literal has a contextual type that includes a ThisType<T>, this has type T.
  • Otherwise, if --noImplicitThis is enabled and the containing object literal has a contextual type that doesn't include a ThisType<T>, this has the contextual type.
  • Otherwise, if --noImplicitThis is enabled this has the type of the containing object literal.
  • Otherwise, this has type any.

Some examples:

// Compile with --noImplicitThis

type Point = {
    x: number;
    y: number;
    moveBy(dx: number, dy: number): void;
}

let p: Point = {
    x: 10,
    y: 20,
    moveBy(dx, dy) {
        this.x += dx;  // this has type Point
        this.y += dy;  // this has type Point
    }
}

let foo = {
    x: "hello",
    f(n: number) {
        this;  // { x: string, f(n: number): void }
    },
}

let bar = {
    x: "hello",
    f(this: { message: string }) {
        this;  // { message: string }
    },
}

In a similar manner, when --noImplicitThis is enabled and a function expression is assigned to a target of the form obj.xxx or obj[xxx], the contextual type for this in the function expression is obj:

// Compile with --noImplicitThis

obj.f = function(n) {
    return this.x - n;  // 'this' has same type as 'obj'
}

obj['f'] = function(n) {
    return this.x - n;  // 'this' has same type as 'obj'
}

In cases where an API produces a this value by transforming its arguments, a new ThisType<T> marker interface can be used to contextually indicate the transformed type. Specifically, when the contextual type for an object literal is ThisType<T> or an intersection including ThisType<T>, the type of this within methods of the object literal is T.

// Compile with --noImplicitThis

type ObjectDescriptor<D, M> = {
    data?: D;
    methods?: M & ThisType<D & M>;  // Type of 'this' in methods is D & M
}

function makeObject<D, M>(desc: ObjectDescriptor<D, M>): D & M {
    let data: object = desc.data || {};
    let methods: object = desc.methods || {};
    return { ...data, ...methods } as D & M;
}

let obj = makeObject({
    data: { x: 0, y: 0 },
    methods: {
        moveBy(dx: number, dy: number) {
            this.x += dx;  // Strongly typed this
            this.y += dy;  // Strongly typed this
        }
    }
});

obj.x = 10;
obj.y = 20;
obj.moveBy(5, 5);

In the example above, the methods object in the argument to makeObject has a contextual type that includes ThisType<D & M> and therefore the type of this in methods within the methods object is { x: number, y: number } & { moveBy(dx: number, dy: number): number }. Notice how the type of the methods property simultaneously is an inference target and a source for the this type in methods.

The ThisType<T> marker interface is simply an empty interface declared in lib.d.ts. Beyond being recognized in the contextual type of an object literal, the interface acts like any empty interface.

Patterns similar to the above are used in several frameworks, including for example Vue and Ember. Using ThisType<T> we can now more accurately describe those frameworks.

Supercedes #8382. We revoked that PR because it always made the type of this in object literal methods be the type of the object literal. We now make that the default behavior, but allow the default to be overridden using a ThisType<T> contextual type.

@ahejlsberg ahejlsberg changed the title Typed 'this Typed 'this' in object literal methods Feb 17, 2017
@ahejlsberg
Copy link
Member Author

ahejlsberg commented Feb 17, 2017

Latest commit adds contextual this type in assignments of the form obj.xxx = function(...) and obj[xxx] = function(...).

@ahejlsberg
Copy link
Member Author

The one issue that remains for discussion here is whether the strongly typed this behavior should always be in effect or only be in effect in --noImplicitThis mode. It is technically a breaking change, but it also provides a better authoring experience (statement completion for this) and catches legitimate bugs.

Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this change.

  1. Still need tests
  2. @bradenhs points out that the contextual typing code only applies to functions and object literal methods. It should apply to object literal getters/setters as well.
  3. I ran RWC to see what breaks. I found several good breaks, a few breaks that require a d.ts update with ThisType (Object.defineProperty, knockout and react), and a couple of places that show that this needs to be the intersection of the object literal type and the parameter type.

}

function getThisTypeFromContextualType(type: Type): Type {
return applyToContextualType(type, t => {
Copy link
Member

@sandersn sandersn Feb 17, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't applyToContextualType just be named mapType and replace mapType? It doesn't do anything specific to contextual types that I could see, and it's better than the current mapType because it skips return values of undefined. #Resolved

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that makes sense.

@@ -41,19 +41,51 @@ tests/cases/conformance/fixSignatureCaching.ts(639,38): error TS2304: Cannot fin
tests/cases/conformance/fixSignatureCaching.ts(640,13): error TS2304: Cannot find name 'window'.
tests/cases/conformance/fixSignatureCaching.ts(641,13): error TS2304: Cannot find name 'window'.
tests/cases/conformance/fixSignatureCaching.ts(704,18): error TS2339: Property 'prepareDetectionCache' does not exist on type '{}'.
tests/cases/conformance/fixSignatureCaching.ts(704,45): error TS2339: Property '_cache' does not exist on type '{ constructor: (userAgent: any, maxPhoneWidth: any) => void; mobile: () => any; phone: () => any; tablet: () => any; userAgent: () => any; userAgents: () => any; os: () => any; version: (key: any) => any; versionStr: (key: any) => any; is: (key: any) => any; match: (pattern: any) => any; isPhoneSized: (maxPhoneWidth: any) => any; mobileGrade: () => any; }'.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of this test isn't obvious without following the pointer back to issue #10697. Can you add one more comment at the top of the file like "this test originally failed by using 100% CPU and should now take less than 1 second to run". I'd like a faster way to know that these additional failures are not a problem.

this.mine
this.willDestroy
//this.willDestroy
Copy link
Member

@sandersn sandersn Feb 17, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just delete this, I think. #Resolved

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@sandersn
Copy link
Member

Summary of RWC breaks [1]: two good breaks from contextual typing of function assignment. Two breaks that show that Object.defineProperty and friends need to include ThisType<T>. One break that shows that choosing the contextual type of the object literal instead of the contextual type from a function call argument is incorrect.

[1] Note that I haven't looked at all of RWC yet; it hit a stack overflow halfway through.

  1. A good break resulting from contextual typing of function assignment.
declare function f(elt: HTMLElement): void;
function outer() {
  var self = this;
  var args: Draggable;
  args.helper = function () {
    return f(this); 
    
      // error 'this: Draggable' is not assignable to 'HTMLElement'
      //   Property 'accessKey' is missing in 'Draggable'
  };
}

This catches code that is just wrong. I looked at Draggable and it's not at all related to HTMLElement.

(This happened in two different projects.)

  1. Another break from contextual typing of function assignment.
// Override the prototype method on C
CBase.prototype.debug = function() {
  var self: C = this;
    // error type 'CBase' is not assignable to 'C'
    //   Property '_subclassProperty' is missing in 'CBase'

I'm not sure what to make of this. The comment makes it sound like the intention is to override the method on C, not on CBase. So probably this is a good error too.

  1. Defining properties on Array.prototype using Object.defineProperties.
Object.defineProperties(Array.prototype, {
  empty: {
    value: function() {
      return this.length === 0;
    }
  }
})

It looks like Object.defineProperties and related methods need to use ThisType to provide the correct type for this here.

  1. Implicit any errors on a this type that has no index signature.
function defineProperty(c: any, name: string, defaultValue: any) {
  var backingFieldName = "_" + name;
  Object.defineProperty(
    c.prototype,
    name,
    {
      get: function(): any {
        if (typeof this[backingFieldName] === 'undefined') {
          this[backingFieldName] = defaultValue;
        }
        return this[backingFieldName];
      }, // code continues ...
    }); 
};

This would also be fixed by adding ThisType to Object.defineProperty because c.prototype: any and would avoid the noImplicitAny error.

  1. Object literal type overrides contextual type:
interface MockJaxOptions {
  url?: string;
  responseTime?: number;
  status?: number;
  responseText?: string;
  responseHeaders: StringMap<string>;
}

$.mockjax({
  type: "GET",
  url: "blah/blah/blah",
  status: 200,
  contentType: "application/json",
  response: function() {
    this.responseText = JSON.stringify({ stuff: "things" });
    // error: property 'responseText' does not exist on '{ type: string ... }'
  }
});

This is bad because $.mockjax shouldn't really need to have its parameter be of type MockJaxOptions & ThisType<MockJaxOptions>.
I have a hunch this is fairly old code because JQuery MockJax now has its own d.ts. So we could fix this particular break in only one place, but it is symptomatic of a larger problem.

@sandersn
Copy link
Member

More breaks:

  1. In vscode/src/vs/languages/markdown/common/markdownWorker.ts:
    	public getEmitOutput(resource: URI, absoluteWorkersResourcePath: string): WinJS.TPromise<Modes.IEmitOutput> { // TODO@Ben technical debt: worker cannot resolve paths absolute
    		let model = this.resourceService.get(resource);
    		let cssLinks: string[] = this.cssLinks || [];
    
    		// Custom Renderer to fix href in images
    		let renderer = new Marked.marked.Renderer();
    		let $this = this;
    		renderer.image = function(href: string, title: string, text: string): string {
    			let out = '<img src="' + $this.fixHref(resource, href) + '" alt="' + text + '"';
    			if (title) {
    				out += ' title="' + title + '"';
    			}
    
    			out += (this.options && this.options.xhtml) ? '/>' : '>';
    			             ~~~~~~~
!!! error TS2339: Property 'options' does not exist on type 'MarkedRenderer'.
    			                             ~~~~~~~
!!! error TS2339: Property 'options' does not exist on type 'MarkedRenderer'.

This seems like a good break. I can't find a property named 'options' in MarkedRenderer or in the class that contains getEmitOutput.

  1. Knockout's observable.fn entries don't specify that this is callable.
ko.observable.fn['toInt'] = function() {
  return parseInt(this());
                  ~~~~~~
!!! error TS2349: Cannot invoke an expression whose type lacks a call signature. Type 'KnockoutObservableFunctions<any>' has no compatible call signatures.
}

This seems like an easy fix to knockout's d.ts. It's just that nobody ever noticed that KnockoutBindingHandler needed to have a line (): any at the beginning.

  1. RxJs/src/observable/dom/AjaxObservable.ts gets contextual typing from assignment
      private setupEvents(xhr: XMLHttpRequest, request: AjaxRequest) {
        const progressSubscriber = request.progressSubscriber;
    
        xhr.ontimeout = function xhrTimeout(e) {
          const {subscriber, progressSubscriber, request } = (<any>xhrTimeout);
          if (progressSubscriber) {
            progressSubscriber.error(e);
          }
          subscriber.error(new AjaxTimeoutError(this, request)); //TODO: Make betterer.
                                                ~~~~
!!! error TS2345: Argument of type 'XMLHttpRequestEventTarget' is not assignable to parameter of type 'XMLHttpRequest'.
!!! error TS2345:   Property 'onreadystatechange' is missing in type 'XMLHttpRequestEventTarget'.
        };

This seems like a good break too. The comment even indicates that the author is suspicious that something is wrong.

  1. Angular 2 decorators_spec.ts
export function main() {
      describe('es5 decorators', () => {
        it('should declare directive class', () => {
          var MyDirective = Directive({}).Class({constructor: function() { this.works = true; }});
                                                                                ~~~~~
!!! error TS2339: Property 'works' does not exist on type '{ constructor: () => void; }'.
          expect(new MyDirective().works).toEqual(true);
        });

This is probably another case where the type of the object literal needs to be intersected with the contextual type from the call signature. But it's in a test. It could just be a completely dynamic use of an undeclared property.

  1. Angular 2 React benchmark
    // tree benchmark in React
    import {getIntParameter, bindAction} from 'angular2/src/test_lib/benchmark_util';
    import * as React from './react.min';
    
    var TreeComponent = React.createClass({
      displayName: 'TreeComponent',
    
      render: function() {
        var treeNode = this.props.treeNode;
                            ~~~~~
!!! error TS2339: Property 'props' does not exist on type '{ displayName: string; render: () => any; }'.

This is evidence that React.createClass will need to update its d.ts too.

@bradenhs
Copy link

bradenhs commented Feb 21, 2017

I just tried this out and thought this would work:

const person = {
  firstName: 'First',
  lastName: 'Last',
  getName() {
    // works!
    return this.firstName + ' ' + this.lastName;
  },
  get name() {
    // 'this' implicitly has type 'any' because it does not have a type annotation.
    return this.firstName + ' ' + this.lastName;
  },
};

But it looks like the getter isn't aware of the type of this. Is this behavior correct?

@sandersn
Copy link
Member

@bradenhs Nope, that is not correct. But the code right now specifically works with the contextual type of this in functions and methods. It needs code to support getters and setters too. I'll update my review comment above so @ahejlsberg will see it when he gets back from vacaation.

@ahejlsberg
Copy link
Member Author

With latest commits we now properly type this in getters and setters.

@@ -22,9 +21,6 @@ tests/cases/conformance/types/thisType/thisTypeInAccessorsNegative.ts(16,22): er
}
const contextual: Foo = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move this test case to thisTypeInAccessors.ts so that we can see the types?

@ahejlsberg
Copy link
Member Author

Latest commit puts all of the new functionality under the --noImplicitThis switch. This ensures there are no breaking changes as a result of this PR since the situations in which we strongly type this in object literal methods were previously errors under --noImplicitThis.

@ahejlsberg ahejlsberg added this to the TypeScript 2.3 milestone Mar 2, 2017
@aozgaa
Copy link
Contributor

aozgaa commented Mar 2, 2017

In the SymbolDisplayBuilder, should we change the following?

                    else if (type.flags & TypeFlags.TypeParameter && (type as TypeParameter).isThisType) {
                        if (inObjectTypeLiteral) {
                            writer.reportInaccessibleThisError();
                        }
                        writer.writeKeyword("this");
                    }

@ahejlsberg
Copy link
Member Author

@aozgaa No, that is an unrelated check (it reports an error if this is accessed in a type position within an object type literal).

@ahejlsberg
Copy link
Member Author

@mhegazy I think this one is good to do. Want to take a look?

@dwickern
Copy link

In this scenario, is it possible to infer this from the containing object literal? Or declare somehow that wrap does not change the context?

let foo = {
    hello: "hello",
    f() {
        return this.hello; // works
    },
    get p() {
        return this.hello; // works
    },
    g: wrap(function () {
        return this.hello; // TS2683:'this' implicitly has type 'any' because it does not have a type annotation.
    })
}

@Jessidhia
Copy link

I haven’t tested it but it should be possible with generics.

declare function wrap<T, F extends (this: T) => any>(f: F): F

@dwickern
Copy link

Unfortunately that doesn't seem to work without specifying wrap<{ hello: string }>(function() { ... }).

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants