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

Intersection of enum union with literal is unexpectedly never #21998

Closed
jcalz opened this issue Feb 16, 2018 · 15 comments
Closed

Intersection of enum union with literal is unexpectedly never #21998

jcalz opened this issue Feb 16, 2018 · 15 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@jcalz
Copy link
Contributor

jcalz commented Feb 16, 2018

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.

@jcalz
Copy link
Contributor Author

jcalz commented Mar 2, 2018

There's another StackOverflow question where my solution breaks because of this.

@jcalz
Copy link
Contributor Author

jcalz commented 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 type V to its representation as a property value of E, then that can be done with conditional types (#21316). We can use this instead of V & E[keyof E]. Here's how:

type AsEnumValue<E extends Record<keyof E, string | number>, V, EV = E[keyof E]>
  = EV extends V ? EV : never

function asEnumValue<E extends Record<keyof E, string | number>, V extends string | number>(
  e: E, v: V ): AsEnumValue<E, V>;
function asEnumValue(e: any, v: any): any {
  if (!Object.keys(e).some(k => e[k] === v)) throw Error("value not in enum")
  return v;
}

And here's how you would use it:

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

declare const a: "a";
const ant = asEnumValue(Bug, a); // Bug.ant
declare const aOrB: "a" | "b";
const bug = asEnumValue(Bug, aOrB); // Bug
declare const aOrBOrC: "a" | "b" | "c";
const alsoBug = asEnumValue(Bug, aOrBOrC); // Bug 
declare const str: string;
const alsoAlsoBug = asEnumValue(Bug, str);  // Bug 

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.

EDIT: Thanks to @kpdonn for his suggestion to fix the above issue.


Also, in practice you want to convert the value of type V to undefined if 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 to never extends X or X extends never must have made this start working):

type AsPossibleEnumValue<E extends Record<keyof E, string | number>, V, EV = E[keyof E]> =
  (
    V extends string | number ?
    (EV extends V ? EV : never) extends never ?
    undefined :
    (EV extends V ? EV : never) : undefined
  ) | (
    string extends V ? undefined : never
  ) | (
    number extends V ? undefined : never
  )

function asPossibleEnumValue<E extends Record<keyof E, string | number>, V extends string | number>(
  e: E, v: V ): AsPossibleEnumValue<E, V>;
function asPossibleEnumValue(e: any, v: any): any {
  return Object.keys(e).some(k => e[k] === v) ? v : void 0;
}

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

declare const a: "a";
const ant = asPossibleEnumValue(Bug, a); // Bug.ant
declare const aOrB: "a" | "b";
const bug = asPossibleEnumValue(Bug, aOrB); // Bug
declare const aOrBOrC: "a" | "b" | "c";
const maybeBug = asPossibleEnumValue(Bug, aOrBOrC); // Bug | undefined
declare const str: string;
const alsoMaybeBug = asPossibleEnumValue(Bug, str); // Bug | undefined

@kpdonn
Copy link
Contributor

kpdonn commented Mar 3, 2018

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.

@jcalz
Copy link
Contributor Author

jcalz commented 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
Copy link
Contributor

mhegazy commented Mar 14, 2018

An enum type is just a union of the enum member types. so Pet is just an alias to Pet.Dog | Pet.Cat.
An enum member type is a tagged version of the type of the literal it is initialized to. So Pet.Dog is a subtype of 1, but it is not 1.

The compiler aggressively normalizes intersections of literals to never if used in a union, since they are empty sets, e.g. 1 & 2 or 1 & "1". In this context, the enum member type is treated as a distinct and unique type. so Pet.Dog & 1 is reduced to never. and since unions distribute over intersections, Pet & 1 is never as well.

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

@mhegazy mhegazy added the Working as Intended The behavior described is the intended behavior; this is not a bug label Mar 14, 2018
@weswigham
Copy link
Member

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

@jcalz
Copy link
Contributor Author

jcalz commented Mar 15, 2018

@mhegazy. Thanks for the attention. I think the argument goes that Pet.dog & 1 should be Pet.dog as opposed to 1, 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, then T & U should be T. If this rule is broken when T is an enum type and U is 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 never even 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
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.

@weswigham weswigham reopened this Mar 29, 2018
@mhegazy mhegazy added Suggestion An idea for TypeScript In Discussion Not yet reached consensus and removed Working as Intended The behavior described is the intended behavior; this is not a bug labels Mar 29, 2018
@haggholm
Copy link

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)

"DataFlowNodeBase": {
  "type": "object",
  "required": ["nodeType"],
  "properties": {
    "nodeType": { "$ref": "./enums.json#/definitions/NodeType" }
  }
},
"DataFlowSink": {
  "allOf": [
    { "$ref": "#/definitions/DataFlowNodeBase" },
    {
      "type": "object",
      "required": ["nodeType", "input", "sinkId"],
      "properties": {
        "sinkId": { "type": "string" },
        "nodeType": { "type": "string", "enum": ["sink"] },
        "input": {}
      }
    }
  ]
}

which gives me

export enum DataFlowNodeType {
	Source = 'source',
	Sink = 'sink',
	// ...
}
export interface DataFlowNodeBase {
	nodeType: DataFlowNodeType;
}
export declare type DataFlowSink = DataFlowNodeBase & {
    sinkId: string;
    nodeType: 'sink';
    input: any;
}

If 'sink' were considered equal to DataFlowNodeType.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 is never.

I could also easily imagine this happening with any framework passing strings from user input/web requests, Swagger, and the like.

@Ghostbird
Copy link

Ghostbird commented 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:

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

@jcalz
Copy link
Contributor Author

jcalz commented Dec 11, 2019

Can you double check that code? keyof typeof A should not be assignable to A in general (the keys and values of enum objects are generally not the same), so I'm very confused what you're doing there.

@Ghostbird
Copy link

Ghostbird commented Dec 12, 2019

Can you double check that code? keyof typeof A should not be assignable to A in general (the keys and values of enum objects are generally not the same), so I'm very confused what you're doing there.

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
Copy link
Contributor Author

jcalz commented Jun 12, 2020

Ugh, this got worse in TS3.6. Now my workaround doesn't even seem to work:

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

type B = Extract<Bug, "b"> // TS3.5-: Bug.bee; TS3.6+: never

Why do you suppose Bug.bee extends "b" ? true : false is true, but Extract<Bug.ant | Bug.bee, "b"> is never?

@captain-yossarian
Copy link

Most recent example from SO

const enum Key {
    FOO = "foo",
}

type MyObj = {
    foo: string
}

type Test<T,> = Key.FOO extends keyof MyObj ? MyObj[Key.FOO] : never

// First and Second are practically the same
type First = Test<any> // never
type Second = Key.FOO extends keyof MyObj ? true : false // true

@RyanCavanaugh
Copy link
Member

I think this is all good now. Annotated inline:

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
// RC: This is a correct error, since Bug & 'a' is never
declare var witness: VerifyMutuallyAssignable<Bug.ant, Bug.ant & 'a'> // okay, as expected

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

// RC: This is a correct error, since Bug & 'a' is never
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
// RC: Not an error anymore
declare var witness: VerifyExtends<Pet & 0, Pet.cat & 0> // error, not expected!!
// RC: Correctly not an error, both operands are never
declare var witness: VerifyMutuallyAssignable<Pet & 'a', never> // okay, not expected!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

9 participants