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

Nested Exclude has unexpected behavior #28824

Closed
ghost opened this issue Dec 3, 2018 · 7 comments · Fixed by #28851
Closed

Nested Exclude has unexpected behavior #28824

ghost opened this issue Dec 3, 2018 · 7 comments · Fixed by #28851
Assignees
Labels
Bug A bug in TypeScript Domain: Conditional Types The issue relates to conditional types Fixed A PR has been merged for this issue

Comments

@ghost
Copy link

ghost commented Dec 3, 2018

TypeScript Version:

$ tsc --version
Version 3.3.0-dev.20181122

Search Terms:
unexpected, exclude
Code

/*
  Some basic definitions of unions
*/
type Union = 'a' | 'b';
type Product<A extends Union, B> = { f1: A, f2: B};
type ProductUnion = Product<'a', 0> | Product<'b', 1>;

/*
  These work as I would expect. Each element in the union is mapped
  the complement or the double complement with nested Exclude
*/
type UnionComplement = {
  [K in Union]: Exclude<Union, K>
};
// {a: "b"; b: "a"}
type UnionComplementComplement = {
  [K in Union]: Exclude<Union, Exclude<Union, K>>
};
// {a: "a"; b: "b"}

/*
  This also works as I would expect
*/
type ProductComplement = {
  [K in Union]: Exclude<ProductUnion, { f1: K }>
};
// {a: Product<'b', 1>; b: Product<'a', 0>}

/*
  Double complement on the other hand doesn't work
*/
type ProductComplementComplement = {
  [K in Union]: Exclude<ProductUnion, Exclude<ProductUnion, { f1: K }>>
};
// {a: ProductUnion; b: ProductUnion}

/*
  Explicit inlining works as I would expect
*/
type First = Exclude<ProductUnion, Exclude<ProductUnion, { f1: 'a' }>>;
// {f1: 'a'; f2: 0}
type Second = Exclude<ProductUnion, Exclude<ProductUnion, { f1: 'b' }>>;
// {f1: 'b'; f2: 1}

/*
  Making parametrized types works as I would expect
*/
type Complementor<T> = {
    [K in Union]: Exclude<T, { f1: K }>
};
type DoubleComplementor<T> = {
    [K in Union]: Exclude<T, Exclude<T, { f1: K }>>
};
type Complement = Complementor<ProductUnion>;
// {a: Product<'b', 1>; b: Product<'a', 0>}
type DoubleComplement = DoubleComplementor<ProductUnion>;
// {a: Product<'a', 0>; b: Product<'b', 0>}

Expected behavior:
I would expect the parametrized and non-parametrized type to work the same way but that's not
the case.

Actual behavior:
See the comments.

Playground Link: link

Related Issues: Some issues related to Pick and Exclude but didn't seem to apply.

@riggs
Copy link

riggs commented Dec 3, 2018

I've run into a similar (likely related) issue, probably also having to do with distribution over Exclude:

const f = <U extends Exclude<any, Function>>(a: U | ( () => U )): U => {
    if (typeof a === "function" ) {
        return a(); // Cannot invoke an expression whose type lacks a call signature.
                    // Type '(() => U) | (U & Function)' has no compatible call signatures.
    }
    return a;
};

My understanding is that U & Function would expand into Exclude<any, Function> & Function, which would presumably be never.

@ghost
Copy link
Author

ghost commented Dec 3, 2018

@riggs I'm confused by what Exclude<any, Function> can mean? I think your intent is to exclude function types. Wouldn't a conditional type be better in that case?

type NonFunction<U> = U extends Function ? never : U;

Ya looking at this I'm not really sure how I would make sense of it so I'm not sure what hope the compiler has.

@riggs
Copy link

riggs commented Dec 3, 2018

From lib.es5.d.ts:

type Exclude<T, U> = T extends U ? never : T;

@jack-williams
Copy link
Collaborator

jack-williams commented Dec 4, 2018

I think this is a smaller repro:

type Union = 'a' | 'b';
type Z<K extends Union> = false extends ([Union] extends [K] ? never : false) ? 'LEFT' : 'RIGHT';

If you hover over Z it appears to have already resolved to RIGHT.

The issue is that the conditional type is getting marked as non-deferred and it seems to be eagerly resolving using the constraint of K. Related code here:

// If this is a distributive conditional type and the check type is generic we need to defer
// resolution of the conditional type such that a later instantiation will properly distribute
// over union types.
const isDeferred = root.isDistributive && maybeTypeOfKind(checkType, TypeFlags.Instantiable);

The actual branch that is getting selected is later:

// Return trueType for a definitely true extends check. The definitely assignable relation excludes
// type variable constraints from consideration. Without the definitely assignable relation, the type
//   type Foo<T extends { x: any }> = T extends { x: string } ? string : number
// would immediately resolve to 'string' instead of being deferred.
if (checkTypeRelatedTo(checkType, inferredExtendsType, definitelyAssignableRelation, /*errorNode*/ undefined)) {
    return instantiateType(root.trueType, combinedMapper || mapper);
}

A candidate fix is:

const isDeferred = maybeTypeOfKind(extendsType, TypeFlags.Instantiable) 
  || root.isDistributive && maybeTypeOfKind(checkType, TypeFlags.Instantiable);

but I think this is probably incomplete and there are other cases to deal with.

@weswigham I think you have an open PR that would fix this, right? (#27932)

@riggs
Copy link

riggs commented Dec 4, 2018

Pardon the side-track: Why wrap Union & K in a 1-tuple?

@jack-williams
Copy link
Collaborator

Essentially the compiler is trying to eagerly resolve the conditional type using wildcard types, substituting type parameters for any. For example: K ==> any, or [K] ==> [any]. The compiler knows that any conditional type with a wildcard extends type resolves to the wildcard type, but it misses the case when the wild card is wrapped in a 1-tuple.

(I think).

@ahejlsberg ahejlsberg self-assigned this Dec 4, 2018
@ahejlsberg ahejlsberg added Bug A bug in TypeScript Domain: Conditional Types The issue relates to conditional types labels Dec 4, 2018
@ahejlsberg ahejlsberg added the Fixed A PR has been merged for this issue label Dec 4, 2018
@ahejlsberg ahejlsberg added this to the TypeScript 3.2.2 milestone Dec 4, 2018
@ghost
Copy link
Author

ghost commented Dec 5, 2018

Amazing turnaround. Thanks @ahejlsberg and team.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript Domain: Conditional Types The issue relates to conditional types Fixed A PR has been merged for this issue
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants