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

Number literal should not extend enum of different value #31834

Closed
AnyhowStep opened this issue Jun 9, 2019 · 8 comments
Closed

Number literal should not extend enum of different value #31834

AnyhowStep opened this issue Jun 9, 2019 · 8 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Jun 9, 2019

TypeScript Version: 3.4.1, 3.5.1

Search Terms: number literal, extend, enum

Code

enum E {
    A = 1,
    B = 2,
}
/**
    Expected: "y"
    Actual  : "y"
    OK!
*/
type oneShouldExtendA = 1 extends E.A ? "y" : "n";
/**
    Expected: "n"
    Actual  : "y"
    ???
*/
type oneShouldNotExtendB = 1 extends E.B ? "y" : "n";

/**
    Expected: "y"
    Actual  : "y"
    OK!
*/
type aShouldExtend1 = E.A extends 1 ? "y" : "n";
/**
    Expected: "n"
    Actual  : "n"
    OK!
*/
type bShouldNotExtend1 = E.B extends 1 ? "y" : "n";

Expected behavior:

oneShouldNotExtendB should be "n"

Actual behavior:

oneShouldNotExtendB is "y"

Playground Link: Playground

Related Issues:

Kind of feels like a different version of #28654 (where true extended false)

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Jun 9, 2019

#12647 seems somewhat related

I must say that I don't like this behaviour of numeric enums.

Luckily, I don't use numeric enums often. Mostly string enums. But that's why I expected numeric enums to work the same.

The whole bit flag thing sounds like a terrible idea. If one is sure (Enum.A | Enum.B) is of type Enum, they should be the ones who are inconvenienced and should add an explicit cast, if TS won't do type-level bitwise-or and check assignability.

Allowing arbitrary numbers to assign to numeric enum types just takes away from the whole type safety thing. It's enough of a deal breaker that I just can't use numeric enums at all.

I tried using numeric enums for a project this weekend and it's just been thoroughly unpleasant.

It's bad for generic code, bad for non-generic code, bad as an index signature, bad in mapped object types, bad for discriminated unions, bad for type safety.

It's a real shame because now I have to union the 50+ http status codes in existence, rather than have a single numeric enum with 50+ elements.

I was getting weird problems where I could assign 200 to an enum called Non2xxStatusCode. Thank God I write compile-time tests and run-time tests.

/Nerd-rage


I understand breaking existing functionality is usually a bad idea but how often is the existing behavior actually desirable?

When I've wanted bit masks, I used int types. Maybe have an enum to represent each flag. But the mask itself is never an enum.

I mean, each flag means 2^(# flags) possible mask values. Unless your enum has 2^(# flags) elements, it really should be an int.

If we can't break the existing behavior, can we introduce a different enum type that behaves like an actual numeric enum?

The existing workaround with namespaces and type unions is just a tonne of boilerplate.

And I would like to actually use a numeric enum, see StatusCode.NOT_FOUND, and have it behave like a regular enum.

Not use some workaround and see 404, 419, 512, etc. How many developers even remember all the uncommon status codes off the top of their head?

/Extra-nerd-rage

@AnyhowStep
Copy link
Contributor Author

Just adding some code as a note to myself,

enum E {
    A = 1,
    B = 2,
}
declare const discriminated: (
    {
        d: E.A,
        value: "A",
    } |
    {
        //E.B is 2
        //2 is 2
        d: E.B | 2,
        value: "B",
    }
);
if (discriminated.d == 1) {
    /**
        Expected: Assignment works
        Actual  : Type '"A" | "B"' is not assignable to type '"A"'.
    */
    const v: "A" = discriminated.value;
}
if (discriminated.d == 2) {
    /**
        Expected: Assignment works
        Actual  : Type '"A" | "B"' is not assignable to type '"B"'.
    */
    const v: "B" = discriminated.value;
}

if (discriminated.d == E.A) {
    /**
        Expected: Assignment works
        Actual  : Type '"A" | "B"' is not assignable to type '"A"'.
    */
    const v: "A" = discriminated.value;
}
if (discriminated.d == E.B) {
    /**
        Expected: Assignment works
        Actual  : Type '"A" | "B"' is not assignable to type '"B"'.
    */
    const v: "B" = discriminated.value;
}
/////////////////////////////////////////////////////

declare const discriminated2: (
    {
        d: E.A,
        value: "A",
    } |
    {
        //E.B is 2
        d: E.B,
        value: "B",
    }
);
if (discriminated2.d == 1) {
    /**
        Expected: Assignment works
        Actual  : Type '"A" | "B"' is not assignable to type '"A"'.
    */
    const v: "A" = discriminated2.value;
}
if (discriminated2.d == 2) {
    /**
        Expected: Assignment works
        Actual  : Type '"A" | "B"' is not assignable to type '"B"'.
    */
    const v: "B" = discriminated2.value;
}

if (discriminated2.d == E.A) {
    /**
        Expected: Assignment works
        Actual  : Assignment works
    */
    const v: "A" = discriminated2.value;
}
if (discriminated2.d == E.B) {
    /**
        Expected: Assignment works
        Actual  : Assignment works
    */
    const v: "B" = discriminated2.value;
}

Playground

@AnyhowStep
Copy link
Contributor Author

More notes,

enum E {
    A = 1,
    B = 2,
    C = 3,
}
declare const discriminated: (
    {
        d: 1,
        value: "A",
    } |
    {
        d: 2,
        value: "B",
    }
);
if (discriminated.d == 1) {
    /**
        Expected: Assignment works
        Actual  : Assignment works
    */
    const v: "A" = discriminated.value;
}
if (discriminated.d == 2) {
    /**
        Expected: Assignment works
        Actual  : Assignment works
    */
    const v: "B" = discriminated.value;
}
/**
    Expected: Equality check not allowed
    Actual  : Equality check not allowed
*/
if (discriminated.d == 3) {
}

//Equality check is allowed, but narrowing not applied?
if (discriminated.d == E.A) {
    /**
        Expected: Assignment works
        Actual  : Type '"A" | "B"' is not assignable to type '"A"'.
    */
    const v: "A" = discriminated.value;
}
//Equality check is allowed, but narrowing not applied?
if (discriminated.d == E.B) {
    /**
        Expected: Assignment works
        Actual  : Type '"A" | "B"' is not assignable to type '"B"'.
    */
    const v: "B" = discriminated.value;
}
/**
    Expected: Equality check not allowed
    Actual  : Equality check allowed
*/
if (discriminated.d == E.C) {
}

Playground

@AnyhowStep
Copy link
Contributor Author

I ended up just writing yet-another-script to generate the namespace+union enum workaround.

@fatcerberus
Copy link

Yeah, long story short: numeric enum types are secretly just aliases for number.

enum E { A = 1, B = 2, C = 3 };

let e: E = E.C;

e = E.A;            // this is perfectly kosher
let n: number = e;  // questionable, but makes sense
e = 812;            // no error. not cool, man!

They're about as typesafe as C enums - which is to say, not typesafe at all. 😛

@jcalz
Copy link
Contributor

jcalz commented Jun 10, 2019

It seems that oneShouldExtendA should also be "n" and not "y". In the perfect parallel universe where bit flags never existed, E is narrower than number; E.A should be equivalent to 1 & E. (Of course that intersection doesn't even work for string enums, see #21998).

@AnyhowStep
Copy link
Contributor Author

AnyhowStep commented Jun 10, 2019

E is narrower than number. But E.A and the literal 1 should be assignable to each other (in my head anyway).

But I wouldn't mind your expected behavior if it's just applied consistently.


I'd like it if both enum elements and number literals narrowed numeric enum types.

It just makes sense in my head that E.A is just an alias for the type 1 and E is just an alias for 1|2. Basically, I want the namespace-workaround to be the "real" behavior of numeric enums.

I had a discriminated union where the http status code, represented with an enum, was the discriminant.

I'd like it if both HttpStatusCode.NOT_FOUND and 404 can be used to narrow the union type.


And there was also other weird behavior where I needed a lot of conditional types to get the "correct" type I was looking for.

@fatcerberus
Copy link

fatcerberus commented Jun 10, 2019

It's not narrower in practice though, because like he said, bitfields are a thing. E.A | E.B (bitwise OR, not a type union) might yield a value not listed in the enum. That said, E.A not being a literal 1 is kind of weird.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

5 participants