microsoft/TypeScript

Suggestion: Type narrowing also narrows conditional types

krryan opened this issue · 6 comments

TypeScript Version: 2.8.0-dev.20180204

Code

declare function broke(impossible: never): never; // used to ensure full case coverage

interface Foo { kind: 'foo'; }
declare function isFoo(foobar: Foo | Bar): foobar is Foo;
interface Bar { kind: 'bar'; }
declare function isBar(foobar: Foo | Bar): foobar is Bar;

function mapFooOrBar<FooBar extends Foo | Bar, R>(
    foobar: FooBar,
    mapFoo: FooBar extends Foo ? ((value: Foo) => R) : ((impossible: never) => never),
    mapBar: FooBar extends Bar ? ((value: Bar) => R) : ((impossible: never) => never),
): R {
    if (isFoo(foobar)) {
        return mapFoo(foobar);
/*             ^^^^^^^^^^^^^^
Cannot invoke an expression whose type lacks a call signature. Type '((value: Foo) => R) | ((impossible: never) => never)' has no compatible call signatures. */
    }
    else if (isBar(foobar)) {
        return mapBar(foobar);
/*             ^^^^^^^^^^^^^^
Cannot invoke an expression whose type lacks a call signature. Type '((value: Bar) => R) | ((impossible: never) => never)' has no compatible call signatures. */
    }
    else {
        return broke(foobar as never);
    }
}

(the casting foobar as never for the broke function is a workaround for #20375)

Expected Behavior
Compile without errors.

Actual Behavior
The types of mapFoo and mapBar are not reconsidered in light of passing the typeguard:
Cannot invoke an expression whose type lacks a call signature. Type '((value: Foo) => R) | ((impossible: never) => never)' has no compatible call signatures.

Cannot invoke an expression whose type lacks a call signature. Type '((value: Bar) => R) | ((impossible: never) => never)' has no compatible call signatures.

My use case for this is a situation where I have a union of three types, but some cases will only ever use a union of two of those types. I want this kind of utility function to be re-usable for those situations, but indicate that the handlers for unincluded cases will never be called (and allow us to type-safely stub those branches, e.g. with that broke function).

Our project uses these kinds of unions and utility functions a lot and this would significantly improve them.

I think this might be related, but it's reversed -- using a conditional mapped type to narrow the original.

declare function log(s: string): void;

type Transforms<T, S> = {
  [K in keyof T]: T[K] extends S ? (undefined | ((d: T[K]) => S)) : ((d: T[K]) => S);
}

function logData<T>(d: T, keys: (keyof T)[], transforms: Transforms<T, string>): void {
  keys.forEach((k) => {
    const t = transforms[k];
    const dk = d[k];
    if (t) log(t(dk));
    else log(dk); // Argument of type 'T[keyof T]' is not assignable to parameter of type 'string'.
  });
}

Updated this quite a bit: the example I had was poor, the typeof "workaround" wasn't and was never going to work, but unfortunately after addressing all of that, the problem remains. It has a workaround, but type inference doesn't seem to work for the workaround (see #22149).

If I've read correctly, I think this is working as intended; in the general case, the type of foobar itself doesn't necessarily reflect that FooBar (the type variable) will describe identical types of a given instantiation. For example:

function compare<T>(x: T, y: T) {
  if (typeof x === "string") {
    y.toLowerCase() // appropriately errors; 'y' isn't suddenly also a 'string'
  }
  // ...
}

// why not?
compare<string | number>("hello", 100);

Ah right, I see what you mean. But this is a shortcoming of TypeScript as well, because the actual definitions I've used cannot cause that problem. The issue is that TS doesn't recognize that Foo & Bar is actually never since the kind property cannot possibly be 'foo' and 'bar' simultaneously. If it recognized that, it could know that you will never get something that simultaneously isFoo and isBar. That means that if you try to explicitly force the generic parameter FooBar to be Foo, either foobar is a Foo (and thus not Bar) and the never in mapBar is perfectly accurate, or foobar is a Bar which would make the never in mapBar wrong, except that foobar wouldn't then be a valid argument to the function in the first place.

Some notion of that sort of mutual exclusivity also seems to be the primary impediment to #20375, which also came up in this example. If we had that, both of these problems would be tractable.

Furthermore, in this case, I don't even want FooBar as a declared/declarable generic parameter, I would gladly use typeof foobar—except typeof foobar is resolved with the declaration of the function, rather than based on the particular foobar passed in with any given invocation.

For example, what I would have liked was

function mapFooOrBar<R>(
    foobar: Foo | Bar,
    mapFoo: typeof foobar extends Foo ? ((value: Foo) => R) : ((impossible: never) => never),
    mapBar: typeof foobar extends Bar ? ((value: Bar) => R) : ((impossible: never) => never),
): R {

but of course, typeof foobar is evaluated immediately as Foo | Bar and so by the time I call mapFooOrBar(foo, it's already too late.

Would suggestions of this mutual exclusivity notion and/or this delayed typeof idea be worthwhile? If so, should they be separate issues, or is post itself a suggestion for them?

@krryan see #14094 for mutually exclusive types

Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.