-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
Comments
There's another StackOverflow question where my solution breaks because of this. |
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 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
EDIT: Thanks to @kpdonn for his suggestion to fix the above issue. Also, in practice you want to convert the value of type 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 |
I read through your workaround and in response to
What you need to do to get function asEnumValue<E extends Record<keyof E, string | number>, V extends string>(
e: E, v: V ): AsEnumValue<E, V>; The key difference there being
Specifically I believe the 2nd bullet there is why adding the |
Yeah, I guess I didn't realize that it would treat an implicitly literally-typed constant differently from an explicitly literally-typed one. Thanks. |
An enum type is just a union of the enum member types. so The compiler aggressively normalizes intersections of literals to never if used in a union, since they are empty sets, e.g. I can see the argument that theoretically |
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). |
@mhegazy. Thanks for the attention. I think the argument goes that 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, 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 Mostly I just want this for consistency so that type manipulation produces predictable results. If If the compiler normalizes intersections of literals to |
Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed. |
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 I could also easily imagine this happening with any framework passing strings from user input/web requests, Swagger, and the like. |
This only works because by convention we kept our string enum keys and values identical foo( bar: keyof typeof A & keyof typeof B ) {
x = bar as A;
y = bar as B;
} |
Can you double check that code? |
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. |
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 |
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 |
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!! |
TypeScript Version: 2.8.0-dev.20180211
Search Terms: enum literal intersection never
Code
Expected behavior:
I expect that
Bug & 'a'
should reduce toBug.ant
, or at least toBug.ant | (Bug.bee & 'a')
.Similarly,
Pet & 0
should reduce toPet.cat
, or at least toPet.cat | (Pet.dog & 0)
.Actual behavior:
Both
Bug & 'a'
andPet & 0
reduce tonever
, which is bizarre to me. I was trying to solve a StackOverflow question and realized that my solution was narrowing literals tonever
after a type guard. Something like: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.
The text was updated successfully, but these errors were encountered: