-
Notifications
You must be signed in to change notification settings - Fork 12.4k
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
Extending string-based enums #17592
Comments
Just played with it a little bit and it is currently possible to do this extension using an object for the extended type, so this should work fine: enum BasicEvents {
Start = "Start",
Finish = "Finish"
};
// extend enum using "extends" keyword
const AdvEvents = {
...BasicEvents,
Pause: "Pause",
Resume: "Resume"
}; |
Note, you can get close with enum E {}
enum BasicEvents {
Start = 'Start',
Finish = 'Finish'
}
enum AdvEvents {
Pause = 'Pause',
Resume = 'Resume'
}
function enumerate<T1 extends typeof E, T2 extends typeof E>(e1: T1, e2: T2) {
enum Events {
Restart = 'Restart'
}
return Events as typeof Events & T1 & T2;
}
const e = enumerate(BasicEvents, AdvEvents); |
Another option, depending on your needs, is to use a union type:
Downside is you can't use |
We need this feature for strongly typed Redux reducers. Please add it in TypeScript. |
Another workaround is to not use enums, but use something that looks like an enum: const BasicEvents = {
Start: 'Start' as 'Start',
Finish: 'Finish' as 'Finish'
};
type BasicEvents = (typeof BasicEvents)[keyof typeof BasicEvents];
const AdvEvents = {
...BasicEvents,
Pause: 'Pause' as 'Pause',
Resume: 'Resume' as 'Resume'
};
type AdvEvents = (typeof AdvEvents)[keyof typeof AdvEvents]; |
All workarounds are nice but I would like to see the enum inheritance support from typescript itself so that I can use exhaustive checks as simple as possible. |
Just use class instead of enum. |
I was just trying this out.
There has got to be a better way of doing this. |
Why isn't this a feature already? No breaking changes, intuitive behavior, 80+ people who actively searched for and demand this feature – it seems like a no-brainer. Even re-exporting enum from a different file in a namespace is really weird without extending enums (and it's impossible to re-export the enum in a way it's still enum and not object and type): import { Foo as _Foo } from './Foo';
namespace Bar
{
enum Foo extends _Foo {} // nope, doesn't work
const Foo = _Foo;
type Foo = _Foo;
}
Bar.Foo // actually not an enum |
+1 |
I skimmed through this issue to see if anyone has posed the following question. (Seems not.) From OP: enum BasicEvents {
Start = "Start",
Finish = "Finish"
};
// extend enum using "extends" keyword
enum AdvEvents extends BasicEvents {
Pause = "Pause",
Resume = "Resume"
}; Would people expect If yes, then how well does that mesh with the fact that enum types are meant to be final and not possible to extend? |
@masak great point. The feature people want here is definitely not like normal |
On that note, I did like the suggestion of spread for this from OP. // extend enum using spread
enum AdvEvents {
...BasicEvents,
Pause = "Pause",
Resume = "Resume"
}; Because spreading already carries the expectation of the original being shallow-cloned into an unconnected copy. |
I can see how that could be true in all cases, but I'm not sure it should be true in all cases, if you see what I mean. Feels like it'd be domain-dependent and rely on the reason those enum values were copied over. |
I thought about workarounds a little more, and working off of #17592 (comment) , you can do a little better by also defining enum BasicEvents {
Start = 'Start',
Finish = 'Finish'
}
enum AdvEvents {
Pause = 'Pause',
Resume = 'Resume'
}
type Events = BasicEvents | AdvEvents;
const Events = {...BasicEvents, ...AdvEvents};
let e: Events = Events.Pause; From my testing, looks like (This is for the case when you want the enum types to be assignable between each other rather than isolated enums. From my experience, it's most common to want them to be assignable.) |
Another suggestion (even though it does not solve the original problem), how about using string literals to create a type union instead?
|
So, the solution to our problems could be this?
|
Extending enums should be a core feature of TypeScript. Just sayin' |
@wottpal Repeating my question from earlier:
Specifically, it seems to me that the totality check of a switch statement over an enum value depends on the non-extensibility of enums. |
@masak What? No, it doesn't! Since extended enum is a wider type and cannot be assigned to the original enum, you always know all the values of every enum you use. Extending in this context means creating a new enum, not modifying the old one. enum A { a; }
enum B extends A { b; }
declare var a: A;
switch(a) {
case A.a:
break;
default:
// a is never
}
declare var b: B;
switch(b) {
case A.a:
break;
default:
// b is B.b
} |
@m93a Ah, so you mean that However, there is some expectation in there that still seems broken to me. As a way to try and nail it down: with classes, Because of this, if But with enums and |
Then just calling it |
I don't think I'm qualified enough to offer my opinion on language design, but I think I can give feedback as a regular developer. I've come upon this issue a couple of weeks earlier in a real project where I wanted to use this feature. I ended up using @alangpierce's approach. Unfortunately, due to my responsibilities to my employer, I can't share the code here, but here are a few points:
Overall, I think that a lot of real projects with boring business logic would benefit a lot from this feature. Splitting enums into different subtypes would allow type system to check a lot of invariants that are now checked by unit tests, and making incorrect values unrepresentable by a type system is always a good thing. |
Hi, Let me add my two cents here 🙂 My contextI have an API, with the OpenApi documentation generated with tsoa. One of my model has a status defined like this: enum EntityStatus {
created = 'created',
started = 'started',
paused = 'paused',
stopped = 'stopped',
archived = 'archived',
} I have a method enum RequestedEntityStatus {
started = 'started',
paused = 'paused',
stopped = 'stopped',
} So my method is described this way: public setStatus(status: RequestedEntityStatus) {
this.status = status;
} with that code, I get this error: Conversion of type 'RequestedEntityStatus' to type 'EntityStatus' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. which I will do for now, but was curious and started searching this repository, when I found this.
My ProposalI found the spread operator better than the extends proposal, but I'd like to go further and suggest these: enum EntityStatus {
created = 'created',
started = 'started',
paused = 'paused',
stopped = 'stopped',
archived = 'archived',
}
enum RequestedEntityStatus {
// Pick/Reuse from EntityStatus
EntityStatus.started,
EntityStatus.paused,
EntityStatus.stopped,
}
// Fake enum, just to demonstrate
enum TargetStatus {
{...RequestedEntityStatus},
// Why not another spread here?
//{...AnotherEnum},
EntityStatus.archived,
}
public class Entity {
private status: EntityStatus = 'created'; // Why not a cast here, if types are compatible, and deserializable from a JSON. EntityStatus would just act as a type union here.
public setStatus(requestedStatus: RequestedEntityStatus) {
if (this.status === (requestedStatus as EntityStatus)) { // Should be OK because types are compatible, but the cast would be needed to avoid comparing oranges and apples
return;
}
if (requestedStatus == RequestedStatus.stopped) { // Should be accessible from the enum as if it was declared inside.
console.log('Stopping...');
}
this.status = requestedStatus;// Should work, since EntityStatus contains all the enum members that RequestedEntityStatus has.
}
public getStatusAsStatusRequest() : RequestedEntityStatus {
if (this.status === EntityStatus.created || this.status === EntityStatus.archived) {
throw new Error('Invalid status');
}
return this.status as RequestedEntityStatus; // We have eliminated the cases where the conversion is impossible, so the conversion should be possible now.
}
} More generally, this should work: enum A { a = 'a' }
enum B { a = 'a' }
const a:A = A.a;
const b:B = B.a;
console.log(a === b);// Should not say "This condition will always return 'false' since the types 'A' and 'B' have no overlap.". They do have overlaps In other words
By adding those abilites to the compiler, two independent enums with the same values can be assigned to one another with the same rules as unions: enum A { a = 'a', b = 'b' }
enum B { a = 'a' , c = 'c' }
enum C { ...A, c = 'c' } And three variables
or maybe we should need an explicit cast on the right-hand sides, as for the equality comparison ? About enum members conflictsI'm not a fan of the "last wins" rule, even if it feels natural with the spread operator. enum A { a = 'a', b = 'b' }
enum B { a = 'a' , c = 'c' }
enum C {...A, ...B } // OK, equivalent to enum C { a = 'a', b = 'b', c = 'c' }, a is deduplicated
enum D {...A, a = 'd' } // Error : Redefinition of a
enum E {...A, d = 'a' } // Error : Duplicate key with value 'a' ClosingI find this proposal quite flexible and natural for TS devs to work with, while allowing more type safety (compared to |
As a workaround, I'm trying Union Types with a guaranteed type Difficulty =
| { type: 'beginner'; weight: 1 }
| { type: 'intermediate'; weight: 2 }
| { type: 'advanced'; weight: 3 };
type PreferredDifficulty = Difficulty | { type: 'random' }; And I use these for different things in my app. For example an interface Exercise {
name: string;
difficulty: Difficulty;
} But a user's workout sure can! interface User {
name: string;
preferredDifficulty: PreferredDifficulty;
}
const generateWorkout = (w: Workout, u: user) => {
if (u.preferredDifficulty.type === 'random') {
// pick random exercises
} else {
// pick exercises suited to their preferred difficulty
}
} |
Hi @RyanCavanaugh, is this feature on the roadmap? |
Hello, any progress? |
So here's the most likely answer at this point - we're probably not going to touch enums for a bit as there's discussion of introducing them to JS; that said, discussion here can help drive the design direction of JS. |
Coming back to this, I don't fully understand the "sugar"/"no sugar" distinction and why it's necessary. Is it just desirable to be able to use either enum for the other? Or is it just that for If it's the latter, could we make a world where for each member of |
What do you think of my proposal here ? My suggestion is to treat the members independently as if they were a type union, to allow them to be assigned in both directions as long as they have a sufficient overlap. using the spread operator allows to create a superset of an enum more simply. The cherry pick syntax ( Do you have a link to the JS proposal? |
IMHO, TypeScript enums should be considered bad practice (and hopefully eslinted-out at some point in future). I believe they originate in times of TypeScript is superset of JavaScript, even though IMO this is contrary to the Design Goals (points 3, 4, 8 and 9). As I hope that now we are back in Typescript is JavaScript + Types world, we should promote idiomatic JS with typechecks instead of custom runtime constructs, that is exactly the approach suggested by @CyberMew above: type BEs = "Start" | "Finish";
type AEs = BEs | "Pause" | "Resume";
let example: AEs = "Finish"; // there is even autocompletion Some additional reference: Should You Use Enums or Union Types in Typescript? |
for now what I have been doing
Thank you!
this allows
|
You can inherit the type from the object, so you don't need to define the values twice. type ConstEnum<T, V = string> = Extract<T[keyof T], V>
const Gender = {
Male: 'male',
Female: 'female'
} as const
type Gender = ConstEnum<typeof Gender>
const gender: Gender = Gender.Female
const ExtendedGender = {
...Gender,
Pansexual: 'pansexual'
} as const
type ExtendedGender = ConstEnum<typeof ExtendedGender>
let extendedGender: ExtendedGender = ExtendedGender.Pansexual
extendedGender = ExtendedGender.Female |
@DanielRosenwasser Ugh, looks like I missed your comment. For the reference this is probably where the mentioned discussion happens: https://github.com/Jack-Works/proposal-enum |
Please, @nazarioa and @freakzlike, refrain from two things:
Also, consider if those pieces of data are needed. Most often they're not. If they are, have at least an "Other" option, although an open field would be better. |
While @patriciavillela's comment might seem like a detour from the main topic, I see a way to connect it to (what I see as) the main important property to preserve in all of this: The original enum must not be affected by the extension. We like enums because of their "closed-world" property — that's the one that gives us totality/coverage checking in From a social perspective, there's simply no way to get the original enum author's blessing — the enum is and remains closed. @patriciavillela was not the original author of the From this point of view, the term "extending" (in the issue topic) is a misnomer; enums simply do not extend. "Inheriting" is half-wrong, half-OK, I guess — you get the original enum's values, but you do not in any sense participate in the original enum's type. @RyanCavanaugh wrote:
I think the big surprise here is relative to expectations set up by terms like "extending". Since we can't actually deliver on that promise without breaking a fundamental guarantee of enums (that of being closed), I'm fine with Option 1, and I think it can be made much less surprising by avoiding terms like "extending". Regarding this:
I guess what I should really be doing is head over to the tc39 repository and defend JavaScript enums against the |
@patriciavillela I meant no offense. I should not have called it sexual orientation and removed male/female. I was trying to create an example that was relatable -- I did it quickly and sloppy. I think I might have had it on my mind for news reasons. I am willing to update the original content in order to make it more correct. |
Per discussion at #51310, we're officially putting this one in "Waiting for TC39" status. There's a proposal that's actively being developed which would potentially bring some sort of A good alternative is: enum X {
one = "a",
two = "b"
}
enum Y {
three = "c",
four = "d"
}
const XY = { ...X, ...Y } as const;
type XY = (typeof XY)[keyof typeof XY]; which will give you a type/value combination that's effectively indistinguishable from a hypothetical |
I would say this is not a "good" alternative, as much as it is just that, an alternative. It's messy, sloppy and extremely hard if not impossible to remember, unless you're a pure TypeScript pro. I don't get why:
is not viable. It's simple, elegant, and consistent with the rest of the TypeScript syntax. |
@whatwhywhenandwho Well... (and sorry for repeating myself; this is well-covered in the comments above, but I realize there's a lot of them, so maybe repeating the message here might be beneficial) ...using Seeing as how the main reason for having enums is to enforce the closed-world assumption and to allow for the exhaustiveness check in |
what about type IncludesType<T extends {[key: string]: string}, U extends {[key: string]: string}> = T & U;
function includes<T extends {[key: string]: string}, U extends {[key: string]: string}>(first: T, second: U): IncludesType<T, U> {
return { ...first, ...second };
}
enum X {
one = "a",
two = "b",
}
enum Y {
three = "c",
four = "d",
}
const XY = includes(X, Y);
/////
function createIncludesFunction<U extends {[key: string]: string}>(baseEnum: U) {
return <T extends {[key: string]: string}>(superset: T): IncludesType<T, typeof baseEnum> => {
return includes(superset, baseEnum);
}
}
const includesY = createIncludesFunction(Y)
const XY = includesY(X)
type XY = ValueOf<typeof Events> // from type-fest |
Thinking about it a little bit philosophically, inheritance between classes is already a pretty weird feature. 😄 It lets you create instances of your own class which are also instances or someone else's class! Like, what's up with that? 😮 But it's something we expect from classes, and sometimes even use in healthy, well-defined ways. (Like extending base classes provided by a framework.) It works. With enums, it runs up against the closed-world expectation, which is why enums never allow you to (a) create new instances/values of the enum besides those originally specified, nor (b) create any kind of derived enum/sub-enum with its own new instances. |
Before string based enums, many would fall back to objects. Using objects also allows extending of types. For example:
When switching over to string enums, it"s impossible to achieve this without re-defining the enum.
I would be very useful to be able to do something like this:
Considering that the produced enums are objects, this won"t be too horrible either:
The text was updated successfully, but these errors were encountered: