TypeScript: Conditional types are incorrectly narrowed

TypeScript Version: 3.1.6, 3.2.1, the current playground (3.3?), and next as of Feb 28

Search Terms: conditional types are incorrectly

Code

interface A { foo(); }
interface B { bar(); }

function test1<T extends A>(x: T, y: T extends B ? number : string) {
    if (typeof y == 'string') {
        y;
    } else {
        y; // never ?
    }
    const newY: string | number = y;
    newY;  // just string
}

function test2<T extends A>(x: T, y: T extends B ? string : number) {
    if (typeof y == 'string') {
        y; // never ?
    } else {
        y; 
    }
    const newY: string | number = y;
    newY;  // just number 
}

Expected behavior: T extends B ? string : number should either be left unchanged, or rounded up to string|number: I think the issue stems from incorrect inference that T extends B is false given T extends A (while they’re just unrelated interfaces that have a non-empty intersection). The test case below is as far as I’ve managed to reduce the problem.

Actual behavior: The T extends A constraint seems to make TS guess T extends B is always false, and so the a?b:c type behaves as c.

Playground Link: (playground)

Related Issues: https://github.com/Microsoft/TypeScript/issues/29939 looks slightly similar, but I don’t see the same constraints when playing around with my example, so I’m not sure it’s the same.

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Reactions: 6
  • Comments: 18 (7 by maintainers)

Most upvoted comments

We are having an issue at work that could be related:

type Primitive = number | string | boolean | null | undefined | Symbol | Function;

export interface ImmutableMap<T> {
    // ...
    toJS(): T;
}

export interface ImmutableList<T extends Array<any>> {
    // ...
    toJS(): T;
}

export type ImmutableFromJS<T> = T extends Primitive ? T
    : T extends Array<any> ? ImmutableList<T>
    : T extends object ? ImmutableMap<T>
    : never;

type Sometype<T> = ImmutableFromJS<{ [P in keyof T]: { [id: string]: T[P]; }; }>;

declare let a: Sometype<object>;
a.toJS(); // OK

declare let b: Sometype<number[]>;
a.toJS(); // OK

declare let c: Sometype<Primitive>;
c!.toJS(); // Error (Not expected)

function test<
    T1,
    T2 extends object,
    T3 extends number[],
    T4 extends Primitive
>(
    arg1: Sometype<T1>,
    arg2: Sometype<T2>,
    arg3: Sometype<T3>,
    arg4: Sometype<T4>
) {
    arg1.toJS(); // Error (Not expected)
    arg2.toJS(); // Error (Not expected)
    arg3.toJS(); // Error (Not expected)
    arg4.toJS(); // Error (Not expected)
}

It seems like when using generics the conditional type is not narrowed down as I would expect.

ImmutableFromJS<{ [P in keyof T]: { [id: string]: T[P]; }; }>

Should be mapped to ImmutableMap<T> always, independently of the type of T?

Please correct me If I’m not understanding this correctly.

That commentary exists within the code that instantiates mapped types, that is, replaces type variables with types. Your example:

declare const tst: { [P in keyof number]: { [id: string]: number[P]; }; };

is a closed type and therefore is not subject to instantiation.

That is to say, when written:

type Mapped<T> = { [P in keyof T]: { [id: string]: T[P]; }; };
declare const tst: Mapped<number>;

there is more than just a basic inlining of number for T going on.

Ye this looks broken to me (and it’s not related to the linked issue). The problem isn’t the narrowing but the computed constraint of the conditional type. Here is a slightly smaller repro that shows the unsoundness you get:

interface A { a: string }
interface B { b: boolean }

function test1<T extends A>(y: T extends B ? B : A): string {
    return y.a;
}

test1<{ a: string, b: boolean }>({ b: true });

Computing the constraint of the conditional type replaces T with its constraint. When resolving the conditional type this makes it always false, so it simplifes to A. Here is the relevant code:

function getConstraintOfDistributiveConditionalType(type: ConditionalType): Type | undefined {
    // Check if we have a conditional type of the form 'T extends U ? X : Y', where T is a constrained
    // type parameter. If so, create an instantiation of the conditional type where T is replaced
    // with its constraint. We do this because if the constraint is a union type it will be distributed
    // over the conditional type and possibly reduced. For example, 'T extends undefined ? never : T'
    // removes 'undefined' from T.
    if (type.root.isDistributive) {
        const simplified = getSimplifiedType(type.checkType);
        const constraint = simplified === type.checkType ? getConstraintOfType(simplified) : simplified;
        if (constraint && constraint !== type.checkType) {
            const mapper = makeUnaryTypeMapper(type.root.checkType, constraint);
            const instantiated = getConditionalTypeInstantiation(type, combineTypeMappers(mapper, type.mapper));
            if (!(instantiated.flags & TypeFlags.Never)) {
                return instantiated;
            }
        }
    }
    return undefined;
}