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

Bring back return type narrowing + fixes #61359

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open

Conversation

gabritto
Copy link
Member

@gabritto gabritto commented Mar 5, 2025

Fixes #33014.
Fixes #33912.

This PR brings back conditional and indexed access return type narrowing from #56941, but with a few updates.
The main motivation behind those updates is described in https://gist.github.com/gabritto/b6ebd5f9fc2bb3cfc305027609e66bca,
but to put it shortly, type narrowing very often cannot distinguish between two non-primitive types,
and since return type narrowing depends on type narrowing to work, it often didn't.

Update 1: non-primitive restriction

To deal with those problematic type narrowing scenarios, the first update is to disallow narrowing of conditional return types that
attempt to distinguish between two non-primitive types.
So this example will not work:

type QuickPickReturn<T extends QuickPickOptions> =
    T extends { canSelectMultiple: true } ? string[] :
    T extends { canSelectMultiple: false } ? string :
    never;
    
type QuickPickOptions = {
    prompt: string;
    items: string[];
    canSelectMultiple: true;
} | {
    prompt: string;
    items: string[];
    canSelectMultiple: false;
}

function showQuickPick<T extends QuickPickOptions>(options: T): QuickPickReturn<T> {
    if (options.canSelectMultiple) {
        return options.items; // Error
    }
    return options.items[0]; // Error
}

That's because the conditional return type QuickPickReturn has one branch with type { canSelectMultiple: true }, which is non-primitive,
and another branch with type { canSelectMultiple: false }, which is also non-primitive.

However, the following will work:

type QuickPickOptions<T extends boolean> = {
    prompt: string;
    items: string[];
    canSelectMultiple: T;
};

type QuickPickReturn<T extends boolean> =
    T extends true ? string[] :
    T extends false ? string :
    never;

function showQuickPick<T extends boolean>(options: QuickPickOptions<T>): QuickPickReturn<T> {
    if (options.canSelectMultiple) {
        return options.items;
    }
    return options.items[0];
}
type QuickPickOptions = {
    prompt: string;
    items: string[];
};

type QuickPickReturn<T extends string | QuickPickOptions> =
    T extends string ? string :
    T extends QuickPickOptions ? string[] :
    never;

function showQuickPick<T extends string | QuickPickOptions>(optionsOrItem: T): QuickPickReturn<T> {
    if (typeof optionsOrItem === "string") {
        return optionsOrItem;
    }
    return optionsOrItem.items;
}

Distinguishing between two primitive types or between a primitive and non-primitive type in the conditional type's branches is allowed.

Update 2: type parameter embedding

We can now detect that a type parameter is a candidate for being used in return type narrowing (i.e. it's a narrowable type parameter) in
cases where the type parameter is indirectly used as a type of a parameter or property.
Before, only this type of usage of T in a parameter type annotation would be recognized:

type PickNumberRet<T> =
    T extends true ? 1 :
    T extends false ? 2 :
    never;

function pickNumber<T extends boolean>(b: T): PickNumberRet<T> {
    return b ? 1 : 2;
}

Now, the following also work:

type PickNumberRet<T> =
    T extends true ? 1 :
    T extends false ? 2 :
    never;

function pickNumber<T extends boolean>({ b }: { b: T }): PickNumberRet<T> {
    return b ? 1 : 2;
}
function pickNumber<T extends boolean>(opts: { b: T }): PickNumberRet<T> {
    return opts.b ? 1 : 2;
}

Combined with the non-primitive restriction mentioned above, this enables users to place narrowable type parameters inside object types
at the exact property that is used for narrowing, so instead of writing this: function fun<T extends { prop: true } | { prop: false }>(param: T): ...,
users can write this: function fun<T extends true | false>(param: { prop: T }): ....

Note that the analysis done to decide if a type parameter is used the parameter in a way that allows it to be narrowed is a syntactical one.
We want to avoid resolving and inspecting actual types during the analysis, because a lot of types are lazy in some sense, and we don't
want this analysis to cause unintended side effects, e.g. circularity errors.
But this means that any usage of a type parameter that requires semantically resolving types to validate it is not going to work.

For a more complete list of what's currently supported here in terms of usages of type parameters, see test tests\cases\compiler\dependentReturnType11.ts.

Update 3: relax extends type restriction

This is a small improvement unrelated to the previous updates.
In the original PR, this was disallowed because we required that a conditional return type's extends types be identical to the types
in the type parameter constraint:

interface Dog {
    name: string;
    bark(): string;
}

type NormalizedRet<T> =
    T extends {} ? T :
    T extends null | undefined ? undefined :
    never;

function normalizeDog<T extends Dog | undefined | null>(dog: T): NormalizedRet<T> {
    if (dog == undefined) {
        return undefined; // Originally an error
    }
    return dog; // Originally an error
}

Note that in NormalizedRet, the first branch's extends type is {}, not Dog, so this wasn't allowed because those types are not identical.
With this PR, we only require that a type in the constraint is assignable to the extends type, i.e. that Dog is assignable to {},
so the code above is now allowed for return type narrowing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
Status: Not started
2 participants