microsoft/TypeScript

Partial type is not narrowed even when its only property is successfully narrowed as being defined

anandthakker opened this issue · 5 comments

TypeScript Version: 3.4.0-dev.20190208

Search Terms: Partial narrow

Code

type T = { content: string[] };

declare function getData(): Partial<T>;

const partial = getData();

let data: T;
if (partial.content) {
    data = partial; // causes an error (unexpected)
    data = { content: partial.content }; // does not cause an error (expected)
}

Expected behavior: No errors. Specifically, the if (partial.content) check narrows partial from Partial<T> to T.

Actual behavior: The following error:

bug.ts:9:5 - error TS2322: Type 'Partial<T>' is not assignable to type 'T'.
  Property 'content' is optional in type 'Partial<T>' but required in type 'T'.

9     data = partial;
      ~~~~


Found 1 error.

Playground Link: playground link

Related Issues: #29496

The check will only narrow the content field. partial is in no way a union so narrowing will be done.

This for example works:

type T = { content: string[] };
declare function getData(): T | { content?: undefined};

const partial = getData();

let data: T;
if (partial.content) {
    data = partial; // ok
    data = { content: partial.content }; //also ok
}

I am pretty sure this is the current designed behavior, but I have seen similar questions come up on SO. The basic expectation being that if a field is narrowed, then, on assignment of the object, that field narrowing is taken into account for compatibility.

This also fails, perhaps better illustrating what I am trying to explain:

type T = { content: "A" | "B" };
declare let data: T;
if (data.content == "A") {
    let justA : { content: "A" } = data // also an error
}

This information should already be available and the assignment seems safe.

The latter example isn't safe because of aliasing, which is non-trivial to track.

type T = { content: "A" | "B" };
declare let data: T;
if (data.content == "A") {
    let justA : { content: "A" } = data;
    data = { content: "B" };
}

A type { content: "A" | "B"} gives you permission to read and write "A" and "B". Witnessing an "A" gives you a snapshot of information about the current state of the field, but it cannot affect the permissions you, or anyone else, holds on the object.

@jack-williams Fair enough. This doesn't work either and it feels like it should:

type T = { content: "A" | "B" };
declare let data: T;
if (data.content == "A") {
    let justA : { content: "A" } = { ...data }; // still an err
}

Here we are copying the data to a new object so changing the value should not be an issue.

Thanks @jack-williams, that makes it pretty clear why the behavior I was expecting can't be possible... it seems so obvious in retrospect!

type T = { content: string[] };
declare let partial: Partial<T>;
if (partial.content) {
    let data: T = partial;
    delete partial.content; // `data` is now mistyped
}

@dragomirtitian Yep, operationally that should work. I think type-checking that is non-trivial under the current infrastructure. You would need to synthesize property access nodes to get the narrowed type of every field and then reconstruct that on the spread, I think.

I'm also not sure what would happen in the case of generics:

function foo<X extends T>(x: X): X {
    if (x.content === "A") {
        return { ...x };
    }
    return x;
}

Doing anything funny with the spread might break existing code that expects the spread to return the exact same type back.