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

Proposal: strictly-typed enums #53013

Open
5 tasks done
bradzacher opened this issue Feb 28, 2023 · 9 comments
Open
5 tasks done

Proposal: strictly-typed enums #53013

bradzacher opened this issue Feb 28, 2023 · 9 comments
Labels
Suggestion An idea for TypeScript Waiting for TC39 Unactionable until TC39 reaches some conclusion

Comments

@bradzacher
Copy link
Contributor

Suggestion

πŸ” Search Terms

enum single type syntax

βœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

I'd like to propose the addition of additional syntax for enums to help constrain them and also allow some DevX improvements.

I've drawn inspiration from flow's syntax for enums as I feel like it's a minimal addition to the syntax whilst also being clear.

enum StringEnum of string {
  A,
  B,
  C,
}

enum NumberEnum of number {
  A,
  B,
  C,
}

Syntax

<enum-declaration> ::= "enum" <Identifier> "{" | "enum" <Identifier> "of" <enum-type> "{"

<Identifier> ::= any name that is a valid JS Identifier

<enum-type> ::= "number" | "string"

Allowed Types

As per the types currently allowed by TS, the allowed enum-type values may only be string or number. In future one could consider extending this to include symbol or boolean to match Flow, but for now we'd just be looking for parity.

When declared with of string, all enum members must be strings. Similarly when declared with of number, all enum members must be numbers.

Value Uniqueness

When declared with of, all enum members must be declared with a unique value.
This constraint also means that referencing other members in the enum is no longer valid, and thus TS need not declare a scope or variables for the enum names.

// regular syntax
enum Foo {
  A = 'A',
  B = A,     // === 'A'
  C = Foo.A, // === 'A'
}

Defaulted vs Initialised Members

String

When declared with of string, any defaulted enum members will have a value equal to the string value of the enum name. Any initialised members will have the value as specified.

enum Foo of string {
  A,         // === 'A'
  BBB_BBB,   // === 'BBB_BBB'
  C = 'Bar', // === 'Bar'
}

Number

When specified with of number, defaulted members will be set to an integer, starting at 0 and incremented for each member.
An enum may declare default or initialised members, though defaulted members may not be used after an initialised member. This constraint exists to make it easier to reason about value uniqueness and ordering.

// βœ… Valid
enum Foo of number {
  A, // === 0
  B, // === 1
  C, // === 2
}

// βœ… Valid
enum Foo of number {
  A = 1,
  B = 2,
  C = 3,
}

// βœ… Valid
enum Foo of number {
  A, // === 0
  B, // === 1
  C = 3,
}

// ❌ Invalid
enum Foo of number {
  A = 1,
  B,
  C,
}

Nominally Typed

Comparisons and assignments of non-enum values to locations typed with an of enum are not allowed.

enum Foo of number { A, B, C }
let foo: Foo = Foo.A;
foo = 1;             // ❌ Invalid
foo += 1;            // ❌ Invalid
foo === 1;           // ❌ Invalid
foo = Foo.A | Foo.B; // ❌ Invalid
declare function acceptsFoo(arg: Foo): void;
acceptsFoo(1);       // ❌ Invalid

enum Bar of string { A, B, C }
let bar: Bar = Bar.A;
bar = 'B';       // ❌ Invalid
bar += 'B';      // ❌ Invalid
bar === 'B';     // ❌ Invalid
declare function acceptsBar(arg: Bar): void;
acceptsBar('B'); // ❌ Invalid

However, enums may be assigned to locations expecting their base types, or may be used in ways afforded by their base types:

enum Foo of number { A, B, C }
const math = Foo.A + Foo.B;  // βœ… Valid
const str = Foo.A.toFixed(); // βœ… Valid

enum Bar of string { A }
const a = Foo.A.charAt(0);   // βœ… Valid

declare function acceptsString(arg: string): void;
acceptsString(Bar.A);        // βœ… Valid

πŸ’» Use Cases

The big things I'm looking to achieve with this proposal are as follows:

  1. provide a way to auto-default string members
    • currently if you want a string enum, you need to specify an initialiser for all enum members, otherwise TS defaults to strings.
    • this is a pretty cumbersome devx for what is a really common usecase.
  2. provide a way to allow users to opt-in to singly-typed enums
    • from my experience it's a very rare case in which you want to use a mixed-type enum, so being able to opt-in to stricter declarations would be good for consistency and correctness.
  3. provide a way to allow users to opt-in to stricter enums
    • as described in many issues and gripes, TS enums are very loose right now and allow a lot of things that are considered loose (eg foo = 'A' or foo === 'A' being valid if there's a member with the value 'A') or even dangerous (eg foo = 99 if there are any number-typed members, even if there are no members with the value 99).
    • it's too big of a change to breaking change the base enum logic, so my thinking is that allowing users to opt-in we can help the ecosystem move to a stricter and safer future without breaking old code.
@RyanCavanaugh
Copy link
Member

This was discussed a lot during the design of #50528 and ultimately decided against due to the potential for TC39 enums to want to use similar syntax. We're basically "hands off" on enum changes until https://github.com/rbuckton/proposal-enum lands one way or another.

@bradzacher
Copy link
Contributor Author

@RyanCavanaugh thanks for adding that link! I hadn't seen the proposal yet.
Good to know that my thinking wasn't too far off what the team was thinking as a standard.

@fatcerberus
Copy link

or even dangerous (eg foo = 99 if there are any number-typed members, even if there are no members with the value 99).

For what it's worth, this is now an error in TS 5.0.

@bradzacher
Copy link
Contributor Author

@jakebailey
Copy link
Member

I'd forgotten about that! But it's still possible to do things like foo = Foo.A + Foo.B and calculate values outside the range.

That's tricky in particular because it'd effectively outlaw flag enums (which selfishly, matters a lot to the TypeScript toolchain internally).

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Waiting for TC39 Unactionable until TC39 reaches some conclusion labels Feb 28, 2023
@BOTKooper
Copy link

until rbuckton/proposal-enum lands one way or another.

well, since it's been stage 0 with no updates (not including subtle link fix yesterday) for almost 5 years isn't it pretty safe to say that tc39 is not interested in it?

@RyanCavanaugh
Copy link
Member

There are ongoing discussions among TC39 members about enums

@shicks
Copy link
Contributor

shicks commented Mar 15, 2023

or even dangerous (eg foo = 99 if there are any number-typed members, even if there are no members with the value 99).

For what it's worth, this is now an error in TS 5.0.

Unfortunately, the recent change doesn't "fix" the weirdness that number extends Foo ? 'broken' : 'accurate' is 'broken' for numeric enums. I don't know how to fix that, but it would be nice to someday.

@bradzacher
Copy link
Contributor Author

That's tricky in particular because it'd effectively outlaw flag enums (which selfishly, matters a lot to the TypeScript toolchain internally).

I think that it's completely okay to outlaw flag enums with new syntax to narrow and strictify enums.
Flags usecases can continue to use core enums without any changes, and any non-flag enum usecases can opt-in to the extra strictness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Suggestion An idea for TypeScript Waiting for TC39 Unactionable until TC39 reaches some conclusion
Projects
None yet
Development

No branches or pull requests

6 participants