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

Suggestion: Type narrowing also narrows conditional types #21879

Closed
krryan opened this issue Feb 12, 2018 · 6 comments
Closed

Suggestion: Type narrowing also narrows conditional types #21879

krryan opened this issue Feb 12, 2018 · 6 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@krryan
Copy link

krryan commented Feb 12, 2018

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.

@krryan krryan changed the title Suggestion: Type narrowing infers conditional types Suggestion: Type narrowing also narrows conditional types Feb 12, 2018
@skeate
Copy link

skeate commented Feb 20, 2018

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'.
  });
}

@krryan
Copy link
Author

krryan commented Feb 23, 2018

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).

@DanielRosenwasser
Copy link
Member

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);

@DanielRosenwasser DanielRosenwasser added the Working as Intended The behavior described is the intended behavior; this is not a bug label Mar 15, 2018
@krryan
Copy link
Author

krryan commented Mar 15, 2018

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?

@RyanCavanaugh
Copy link
Member

@krryan see #14094 for mutually exclusive types

@typescript-bot
Copy link
Collaborator

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

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
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

5 participants