Skip to content

Type narrowing of tagged union this lost inside arrow function in class methods #29260

Description

@calebharris

TypeScript Version: 3.2.2, 3.3.0-dev.20190104

Search Terms:
this type discriminated union narrowing

Code

// A *self-contained* demonstration of the problem follows...
// Test this by running `tsc` on the command-line, rather than through another build tool such as Gulp, Webpack, etc.
class Baz {
  test(this: Foo | Bar) {
    if (this.tag === "bar") return false;

    // this.p is correct here...
    console.log(this.p);

    // but not here...
    const x = run(() => this.p);

    // yet it works if we assign `this` to something else
    const self = this;
    const y = run(() => self.p);
  }
}

class Foo extends Baz {
  tag: "foo" = "foo";
  p: boolean = true;
}

class Bar extends Baz {
  tag: "bar" = "bar";
}

function run(f: () => boolean) {
  return f();
}

function id(b: boolean) {
  return b;
}

function bat(baz: Foo | Bar) {
  if (baz.tag === "bar") return;

  // it also works outside of class methods
  const x = run(() => baz.p);
}

Expected behavior:
Code compiles correctly

Actual behavior:
Compilation fails with the following message:

test.ts:9:30 - error TS2339: Property 'p' does not exist on type 'Foo | Bar'.
  Property 'p' does not exist on type 'Bar'.

9     const x = run(() => this.p);
                               ~


Found 1 error.

Playground Link:
https://www.typescriptlang.org/play/index.html#src=class%20Baz%20%7B%0D%0A%20%20test(this%3A%20Foo%20%7C%20Bar)%20%7B%0D%0A%20%20%20%20if%20(this.tag%20%3D%3D%3D%20%22bar%22)%20return%20false%3B%0D%0A%0D%0A%20%20%20%20%2F%2F%20this.p%20is%20correct%20here...%0D%0A%20%20%20%20console.log(this.p)%3B%0D%0A%0D%0A%20%20%20%20%2F%2F%20but%20not%20here...%0D%0A%20%20%20%20const%20x%20%3D%20run(()%20%3D%3E%20this.p)%3B%0D%0A%0D%0A%20%20%20%20%2F%2F%20yet%20it%20works%20if%20we%20assign%20%60this%60%20to%20something%20else%0D%0A%20%20%20%20const%20self%20%3D%20this%3B%0D%0A%20%20%20%20const%20y%20%3D%20run(()%20%3D%3E%20self.p)%3B%0D%0A%20%20%7D%0D%0A%7D%0D%0A%0D%0Aclass%20Foo%20extends%20Baz%20%7B%0D%0A%20%20tag%3A%20%22foo%22%20%3D%20%22foo%22%3B%0D%0A%20%20p%3A%20boolean%20%3D%20true%3B%0D%0A%7D%0D%0A%0D%0Aclass%20Bar%20extends%20Baz%20%7B%0D%0A%20%20tag%3A%20%22bar%22%20%3D%20%22bar%22%3B%0D%0A%7D%0D%0A%0D%0Afunction%20run(f%3A%20()%20%3D%3E%20boolean)%20%7B%0D%0A%20%20return%20f()%3B%0D%0A%7D%0D%0A%0D%0Afunction%20id(b%3A%20boolean)%20%7B%0D%0A%20%20return%20b%3B%0D%0A%7D%0D%0A

Related Issues:
Issue #28763 might be related, but I'm not sure.

Activity

  1. AnyhowStep commented on Jan 4, 2019

    @AnyhowStep
    Contributor

    In the general case, it's possible that "this" will change its structure before a callback is executed

  2. calebharris commented on Jan 4, 2019

    @calebharris
    Author

    AnyhowStep is that not also true in the case where we assign this to a new variable, self, and use that variable in the closure?

  3. weswigham commented on Jan 4, 2019

    @weswigham
    Member

    It's generally because your callback may not be immediately invoked, so this may be mutated before your callback is executed. A const reassignment is used as a hint that it's unlikely to change.

  4. calebharris commented on Jan 9, 2019

    @calebharris
    Author

    I'm trying to follow this reasoning, but it seems to me that this can not be mutated in a way that would make this.p invalid inside the arrow function without jumping through significant hoops to work around the type system.

  5. weswigham commented on Jan 9, 2019

    @weswigham
    Member

    A simple delete this.p would make the assumption of p existing invalid.

  6. sethfowler commented on Jan 10, 2019

    @sethfowler

    Wesley Wigham (@weswigham), I have to agree with Caleb (@calebharris) on this; that reasoning does not make sense to me. By that logic, type narrowing should be invalidated after function calls, await, any use of for...of or the spread operator (which might invoke a side-effecting iterator implementation), any operation that might trigger a side-effecting toString(), or any property access (which might invoke a side-effecting getter or Proxy). In any of those situations, some code may reach in and unexpectedly mutate this in a way which would invalidate the type narrowing.

    The case of await really makes it clear how inconsistent this behavior is:

    type DivElementlike = Elementlike & { tag: 'div' };
    type SpanElementlike = Elementlike & { tag: 'span' };
    
    function useDiv(div: 'div') { }
    async function doSomethingAsync() { }
    
    class Elementlike {
        public tag: string;
        private _content: string;
    
        hasTag(t: 'div'): this is DivElementlike;
        hasTag(t: 'span'): this is SpanElementlike;
        hasTag(t: string): boolean { return this.tag === t; }
    
        async doSomething(): Promise<void> {
            if (this.hasTag('div')) {
                // This useDiv() call type checks.
                useDiv(this.tag);
    
                // This useDiv() call also type checks, even though it will run at some
                // arbitrary future time. 
                await doSomethingAsync();
                useDiv(this.tag);
    
                // This useDiv() call does not type check, because the type of 'this'
                // is not narrowed inside the arrow function. However, this behaves
                // the same as the 'await' call above!
                return doSomethingAsync().then(() => {
                    useDiv(this.tag);
                });
            }
        }
    }

    (Here's that code on the playground.)

    It seems to me that changing this behavior would be a win. It aligns better with user expectations and makes type predicates more useful, and keeping this limitation doesn't really improve safety significantly.

  7. weswigham commented on Jan 10, 2019

    @weswigham
    Member

    You can read #9998 for details on why we bound our analysis where we do today.

  8. sethfowler commented on Jan 10, 2019

    @sethfowler

    I read through it. Those tradeoffs do make a great deal of sense to me, and I can understand that you have to draw them somewhere.

    I don't want to construct a strawman if it's not what you intended, but a major topic in that discussion is the impracticality of interprocedural control flow analysis. I do want to point out that the issue we're discussing here doesn't require any sort of interprocedural analysis or inlining.

    What I would like to see, ideally, is this. (Pretend for a moment that this parameters are allowed on arrow functions.) Currently the arrow function inside doSomething() is treated as if it had this type:

    (this: Elementlike) => any

    However, I'd like its type to reflect the narrowing of this by the type predicate:

    (this: Elementlike & { tag: 'div' }) => any

    Nothing meaningfully interprocedural is happening there; this is just a change in how values within a single function are typed.

    Again, hopefully I'm not just setting up a strawman, but that seemed worth pointing out.

  9. calebharris commented on Jan 11, 2019

    @calebharris
    Author

    I've updated the sample code with the following snippet:

    function bat(baz: Foo | Bar) {
      if (baz.tag === "bar") return;
    
      // it also works outside of class methods
      const x = run(() => baz.p);
    }

    This compiles just fine, even though by my reading, it's the same situation. I read through #9998 and have a better understanding now of the trade-offs we're dealing with here. However, I feel like the behavior could/should be more consistent.

  10. schmod commented on May 2, 2019

    @schmod

    This issue seems to frequently affect me when writing React code that utilizes nested render() methods, ie:

    type State = { status: 'LOADING' } | { status: 'READY'; data1: string; data2: number };
    
    class MyComponent extends React.Component<any, State> {
    public render() {
      return (<div>
        {this.state.status === 'READY' && (
          <Formik
             initialValues={this.state.data1} /* <-- This is OK */
             render={(props) => (<OtherComponent data={this.state.data2} {...props} />)} /* <-- this.state loses narrowing here */
          />
        )}
      </div>);
    }
    }

    In the above example, even though I've narrowed the tagged union, TypeScript won't let me use it in the inner render() method (even though TS did correctly narrow the type of this.state when it's used as a component property).

    TypeScripts behavior indeed seems baffling at first, as this.state is actually Readonly<State> inside of a React Component class, meaning that the properties on this.state shouldn't ever change.

    However, (thanks to Wesley Wigham (@weswigham)'s helpful link above!) I now understand that the TypeScript compiler is actually worried that the entirety of this or this.state might be swapped out between the time that my outer render() method is called, and the time that Formik's inner render() is called.

    In this specific instance, this wouldn't be a problem in practice, but it's impossible for Typescript to know that Formik's inner render() is called immediately, and has no side-effects.


    The solution to this problem ends up being rather easy. We just need to get a hold of a value of state that we know won't change:

    type State = { status: 'LOADING' } | { status: 'READY'; data1: string; data2: number };
    
    class MyComponent extends React.Component<any, State> {
    public render() {
      const state = this.state;
      return (<div>
        {state.status === 'READY' && (
          <Formik
             initialValues={state.data1}
             render={(props) => (<OtherComponent data={state.data2} {...props} />)}
          />
        )}
      </div>);
    }
    }

    I've posted a variant of the above example to CodeSandbox if anybody wants to play around with it.

  11. typescript-bot commented on Jul 13, 2019

    @typescript-bot
    Contributor

    This issue has been marked as 'Question' and has seen no recent activity. It has been automatically closed for house-keeping purposes. If you're still waiting on a response, questions are usually better suited to stackoverflow.

  12. locked as resolved and limited conversation to collaborators on Oct 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    QuestionAn issue which isn't directly actionable in code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions