Typescript: Nested `Exclude` has unexpected behavior

Created on 3 Dec 2018  ·  7Comments  ·  Source: microsoft/TypeScript


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.

Bug Conditional Types Fixed

Most helpful comment

Amazing turnaround. Thanks @ahejlsberg and team.

All 7 comments

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.

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

From lib.es5.d.ts:

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

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)

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

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

Amazing turnaround. Thanks @ahejlsberg and team.

Was this page helpful?
0 / 5 - 0 ratings