Skip to content

Typescript loses refinement information when overwriting a local variable with its same type #36579

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

Closed
EdwardDrapkin opened this issue Feb 3, 2020 · 6 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@EdwardDrapkin
Copy link

EdwardDrapkin commented Feb 3, 2020

When you refine a variable with a user defined type guard, and then reassign that variable to the result of a function returning the same type, TS loses track of the refined type.

Search Terms:
refinement

Code

interface A { _a: true } 

const isA = (item: any): item is A => true;

declare const foo: any;

function example<T extends {}>(input: T): T { 
    return input;
}

function exampleUsage<T extends {}>(item: T) {
    if (isA(item)) {
        // works
        console.log(item._a);
    }

    item = example(item);

    if (isA(item)) {
        // works
        console.log(item._a);
    }

    if (isA(item)) {
        item = example(item);
        console.log(item._a);
    }
}

Expected behavior:
In the final case, because the function example returns the same type as it was provided, TS should keep track of item's refined type.

Actual behavior:
TS loses track of the fact that item has previously been refined to an instance of A.

Playground Link: http://www.typescriptlang.org/play/#code/JYOwLgpgTgZghgYwgAgILIN7IPpwFzJhQCuKAvsgFCUID2IAzmMsA+gLzIAUwkAtgTggAngEoCvCHxYM0ydgD5CJCAG5qAEwgIANnCgo6jZjFq1BI9ZRjEQCMMHrIIADzh8ADjogAeACrOLpAgGrIYZAo8IB7EYAR+4sgBWJTIacgGYMRQICzRsepk1DZ2Dk6u7l4QAKoMcADmvgGuwaGYETz88aKYqenAMNysqJ1Soj0YfenpAPQzyADutFAA1gxT08hGDLTeAHQ6tPWjfHu4ourTRRuS0pwVnt4nF9TTA0NszxMb03OLy2sfultrsIAcjicznAXldXv1BjxPrdxr1Nv1+PJApUnsjLmitvQdvtDsdblCYekimQgA

Related Issues: n/a

@jcalz
Copy link
Contributor

jcalz commented Feb 3, 2020

This is probably a duplicate of #27706, and related to #16976.

@nmain100
Copy link

nmain100 commented Feb 3, 2020

I don't think #27706 is relevant, as this example doesn't require previous narrowings to be saved in order to work. The core problem, probably related to #16976, is that typescript doesn't narrow on arbitrary assignments, so this:

item = example(item);

does not produce any narrowing.

You only get that behavior in certain cases with a union:

type A = { a: number }
type B = { b: number }

function f(ab: A | B) {
    ab = { a: 4 }
    ab; // A.  In this special case with a union, narrowing happens
}

function i(a: A) {
    a = 0 as any as A & B;
    a; // still A, not A & B
}

function p(n: number) {
    n = 4;
    n; // still number, not 4 or number & 4
}

I think this doesn't happen for performance reasons, but I can't find a ticket that mentions it in more detail.

@EdwardDrapkin
Copy link
Author

It's not that TS doesn't narrow on assignment, it's that it appears to re-widen the type.

The call item = example(item); seems to be invoked with its generic parameter as the enclosing function's generic parameter T, rather than the local variable's actual refined type of T extends A that isA proved, so when example() returns the type it was invoked with, its the enclosing generic T and it looks like TS has widened the type. It's almost as if the type guard doesn't take effect when the refined target is passed as a generic parameter to a function.

@RyanCavanaugh RyanCavanaugh added Working as Intended The behavior described is the intended behavior; this is not a bug Bug A bug in TypeScript and removed Working as Intended The behavior described is the intended behavior; this is not a bug labels Feb 26, 2020
@RyanCavanaugh RyanCavanaugh added this to the TypeScript 4.0 milestone Feb 26, 2020
@RyanCavanaugh
Copy link
Member

@ahejlsberg this seems a little off here:

interface A { _a: true } 

declare function isA(item: any): item is A;
declare function identity<T>(input: T): T;

function exampleUsage<T>(item: T) {
    if (isA(item)) {
        // ok
        item._a;
        item = identity(item);
        // error
        item._a;
    }

    if (isA(item)) {
        const item2 = identity(item);
        // ok
        item._a;
        // ok
        item2._a;
    }
}

@RyanCavanaugh RyanCavanaugh added Working as Intended The behavior described is the intended behavior; this is not a bug and removed Bug A bug in TypeScript labels Apr 21, 2020
@RyanCavanaugh
Copy link
Member

Well, this one's tricky. The TLDR is that TypeScript, intentionally, has different rules around narrowing and initialization inference.

The behavior follows from these rules, none of which are obviously wrong:

  1. When a user-defined type guard narrows a variable, that narrowing always does something (usually union filtering, but potentially intersecting as in this case)
  2. Variables initialized with some expression always assume the widened form of the initializer's type
  3. Assignment will only create a narrowing to a more-specific type if the declared type of the assigned variable is a union

I think rules 1 and 2 are fairly self-evident. Rule 3 is trickier and requires thinking about what life would be like if this weren't the case.

The intuition is that the inferred type of this function should be HTMLElement, not HTMLDivElement | HTMLSpanElement:

function getSomething() {
    let x: HTMLElement;
    if (Math.random() > 0.5) {
        x = getSomeDiv();
    } else {
        x = getSomeSpan();
    }
    return x;
}

and that the return type of this function should be string, not string | number:

function getSomethingElse() {
    // Initializer has type string | number
    let s = getSomeStringOrNumber();
    if (typeof s === "number") {
        s = s.toFixed();
    }
    return s;
}

@RyanCavanaugh RyanCavanaugh removed this from the TypeScript 4.0 milestone Apr 21, 2020
@typescript-bot
Copy link
Collaborator

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

6 participants