Repository navigation
Closed
Labels
In DiscussionNot yet reached consensusNot yet reached consensusSuggestionAn idea for TypeScriptAn idea for TypeScript
Description
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?
Related Issues:
I'm really not finding any, after searching for an hour. A few near misses but nothing that seems particularly relevant.
Reacted by Peter Flynn
Metadata
Metadata
Assignees
Labels
In DiscussionNot yet reached consensusNot yet reached consensusSuggestionAn idea for TypeScriptAn idea for TypeScript
Activity
jcalz commentedon Mar 2, 2018
There's another StackOverflow question where my solution breaks because of this.
jcalz commentedon Mar 2, 2018
I will document my workaround here (although since I'm apparently the only person who has paid attention to this, I guess this might be a note-to-self. hello, me!)
If you have an enum object of type
E, and want to convert a value of typeVto its representation as a property value ofE, then that can be done with conditional types (#21316). We can use this instead ofV & E[keyof E]. Here's how:And here's how you would use it:
Do note that something wonky with inferring literal types forconstvariables (#10676) seems to evaluateAsEnumValue<>before the variable has narrowed to the literal:In the above,shouldBeAntis typed as ifreallyAwere just of typestring, and not"a". Not sure what's going on. Beware, self.EDIT: Thanks to Kevin Donnelly (@kpdonn) for his suggestion to fix the above issue.
Also, in practice you want to convert the value of type
Vtoundefinedif it is not in the enum, which can be done but is more cumbersome. Note this requires conditional types (#21316) at least as of 2.8.0-dev.20180302 (some recent fix tonever extends XorX extends nevermust have made this start working):kpdonn commentedon Mar 3, 2018
I read through your workaround and in response to
What you need to do to get
asEnumValueto infer a literal type forreallyAis change the definition ofasEnumValuetoThe key difference there being
V extends stringinstead of justV. Adding theextends stringconstraint 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:Specifically I believe the 2nd bullet there is why adding the
extends stringprevents the widening.jcalz commentedon Mar 3, 2018
Yeah, I guess I didn't realize that it would treat an implicitly literally-typed constant differently from an explicitly literally-typed one. Thanks.
mhegazy commentedon Mar 14, 2018
An enum type is just a union of the enum member types. so
Petis just an alias toPet.Dog | Pet.Cat.An enum member type is a tagged version of the type of the literal it is initialized to. So
Pet.Dogis a subtype of1, but it is not1.The compiler aggressively normalizes intersections of literals to never if used in a union, since they are empty sets, e.g.
1 & 2or1 & "1". In this context, the enum member type is treated as a distinct and unique type. soPet.Dog & 1is reduced to never. and since unions distribute over intersections,Pet & 1isneveras well.I can see the argument that theoretically
Pet.Dog & 1should be1, but do you have a compelling practical scenario for it?weswigham commentedon Mar 14, 2018
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).
jcalz commentedon Mar 15, 2018
Mohamed Hegazy (@mhegazy). Thanks for the attention. I think the argument goes that
Pet.dog & 1should bePet.dogas opposed to1, but yes, that's the general idea.The practical scenarios I've run into look like control flow narrowing when different enum types have the same values or when someone uses the widened value type to compare against the enum. Like,
if (pet === 1) { // never?! }.In the specific linked Stack Overflow question, I wanted to write the function which takes a literal argument and produces the corresponding enum element from it in a way that the compiler knew which one it is. This is a fairly straightforward function whose return type would be the intersection in question if it didn't reduce to
never.Mostly I just want this for consistency so that type manipulation produces predictable results. If
T extends U, thenT & Ushould beT. If this rule is broken whenTis an enum type andUis a literal or union of literals, well that's a pothole in the roadway of the type system. I can probably avoid the pothole, but I can't help thinking someone should fix it.If the compiler normalizes intersections of literals to
nevereven when they have a non-empty intersection, then it's a bug or a design limitation. I'm kind of confused about why it would be working as intended.typescript-bot commentedon Mar 29, 2018
Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.
haggholm commentedon Aug 29, 2019
This can play very poorly with code generation tools.
I’m currently running into this in the context of TS generated from a JSON schema (using https://www.npmjs.com/package/json-schema-to-typescript), where it’s wreaking havoc with my tagged unions. For instance, I have (simplified)
which gives me
If
'sink'were considered equal toDataFlowNodeType.Sink, this would work beautifully, and in fact it seems to have somehow worked with TS 3.5.3, but with 3.6.2, the intersection isnever.I could also easily imagine this happening with any framework passing strings from user input/web requests, Swagger, and the like.
Ghostbird commentedon Dec 11, 2019
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:jcalz commentedon Dec 11, 2019
Can you double check that code?
keyof typeof Ashould not be assignable toAin general (the keys and values of enum objects are generally not the same), so I'm very confused what you're doing there.Ghostbird commentedon Dec 12, 2019
Ah, that's a tricky thing indeed. We've by convention kept our enum keys and values identical for string enums, so that's why it works. I can see that this is very confusing advice. I'll remove it.
I wasn't exactly sure on how Typescript handles enum typing as it seems to make some exceptions to the standard rules. Some of the things that surprisingly worked and I thought were “exceptions” were actually side effects of our convention that makes keys and values interchangeable.
jcalz commentedon Jun 12, 2020
Ugh, this got worse in TS3.6. Now my workaround doesn't even seem to work:
Why do you suppose
Bug.bee extends "b" ? true : falseistrue, butExtract<Bug.ant | Bug.bee, "b">isnever?captain-yossarian commentedon Nov 13, 2020
Most recent example from SO
RyanCavanaugh commentedon Jan 24, 2023
I think this is all good now. Annotated inline: