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.
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 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
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.
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 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?
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).
@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.
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 '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.
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;
}
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.
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.
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 Bug.bee extends "b" ? true : false is true, but Extract<Bug.ant | Bug.bee, "b"> is never?
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