TypeScript: Intersection of enum union with literal is unexpectedly `never`

TypeScript Version: 2.8.0-dev.20180211

Search Terms: enum literal intersection never

Code

type VerifyExtends<A, B extends A> = true
type VerifyMutuallyAssignable<A extends B, B extends C, C=A> = true

// string enum
enum Bug {
  ant = "a",
  bee = "b"  
}

declare var witness: VerifyExtends<'a', Bug.ant> // okay, as expected
declare var witness: VerifyExtends<'b', Bug.ant> // error, as expected

declare var witness: VerifyMutuallyAssignable<Bug, Bug.ant | Bug.bee> // okay, as expected
declare var witness: VerifyMutuallyAssignable<Bug.ant, Bug.ant & 'a'> // okay, as expected

declare var witness: VerifyExtends<Bug, Bug.ant> // okay as expected
declare var witness: VerifyExtends<Bug & 'a', Bug.ant & 'a'> // error, not expected!!

declare var witness: VerifyMutuallyAssignable<Bug & 'a', never> // okay, not expected!!

// numeric enum
enum Pet {
  cat = 0,
  dog = 1  
}

declare var witness: VerifyExtends<0, Pet.cat> // okay, as expected
declare var witness: VerifyExtends<1, Pet.cat> // error, as expected

declare var witness: VerifyMutuallyAssignable<Pet, Pet.cat | Pet.dog> // okay, as expected
declare var witness: VerifyMutuallyAssignable<Pet.cat, Pet.cat & 0> // okay, as expected

declare var witness: VerifyExtends<Pet, Pet.cat> // okay, as expected
declare var witness: VerifyExtends<Pet & 0, Pet.cat & 0> // error, not expected!!

declare var witness: VerifyMutuallyAssignable<Pet & 'a', never> // okay, not expected!!

Expected behavior: I expect that Bug & 'a' should reduce to Bug.ant, or at least to Bug.ant | (Bug.bee & 'a').
Similarly, Pet & 0 should reduce to Pet.cat, or at least to Pet.cat | (Pet.dog & 0).

Actual behavior: Both Bug & 'a' and Pet & 0 reduce to never, which is bizarre to me. I was trying to solve a StackOverflow question and realized that my solution was narrowing literals to never after a type guard. Something like:

declare function isBug(val: string): val is Bug
declare const a: "a" 
if (isBug(a)) {
  a // never?!
}

Thoughts?

Playground Link:

Related Issues: I’m really not finding any, after searching for an hour. A few near misses but nothing that seems particularly relevant.

About this issue

  • Original URL
  • State: closed
  • Created 6 years ago
  • Comments: 15 (10 by maintainers)

Most upvoted comments

This only works because by convention we kept our string enum keys and values identical I ran into a similar issue, where I had to restrict a function argument string literal to the intersection of two string literal enums. I solved it by unpacking the values from the enums and intersecting the two sets, then casting the argument as appropriate inside the function:

foo( bar: keyof typeof A & keyof typeof B ) {
    x = bar as A;
    y = bar as B;
}

I can see the argument that theoretically Pet.Dog & 1 should be 1, but do you have a compelling practical scenario for it?

We mentioned this during last friday’s design meeting and said we needed to do it to use conditionals for more correct control flow things (otherwise switch case exhaustiveness breaks).

I read through your workaround and in response to

Do note that something wonky with inferring literal types for const variables (#10676) seems to evaluate AsEnumValue<> before the variable has narrowed to the literal:

const reallyA = "a"; // inferred as type "a"
const shouldBeAnt = asEnumValue(Bug, reallyA)  // Bug ?!

In the above, shouldBeAnt is typed as if reallyA were just of type string, and not “a”. Not sure what’s going on. Beware, self.

What you need to do to get asEnumValue to infer a literal type for reallyA is change the definition of asEnumValue to

function asEnumValue<E extends Record<keyof E, string | number>, V extends string>(
  e: E, v: V ): AsEnumValue<E, V>;

The key difference there being V extends string instead of just V. Adding the extends string constraint stops Typescript from widening the type. It’s been a while since I learned that trick but I think it’s specified by this snippet from #10676:

During type argument inference for a call expression the type inferred for a type parameter T is widened to its widened literal type if:

  • all inferences for T were made to top-level occurrences of T within the particular parameter type, and
  • T has no constraint or its constraint does not include primitive or literal types, and
  • T was fixed during inference or T does not occur at top-level in the return type.

An occurrence of a type X within a type S is said to be top-level if S is X or if S is a union or intersection type and X occurs at top-level within S.

Specifically I believe the 2nd bullet there is why adding the extends string prevents the widening.