Typescript: Extending string-based enums

Created on 3 Aug 2017  ·  68Comments  ·  Source: microsoft/TypeScript

Before string based enums, many would fall back to objects. Using objects also allows extending of types. For example:

const BasicEvents = {
    Start: "Start",
    Finish: "Finish"
};

const AdvEvents = {
    ...BasicEvents,
    Pause: "Pause",
    Resume: "Resume"
};

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:

enum BasicEvents {
    Start = "Start",
    Finish = "Finish"
};

// extend enum using "extends" keyword
enum AdvEvents extends BasicEvents {
    Pause = "Pause",
    Resume = "Resume"
};

Considering that the produced enums are objects, this won"t be too horrible either:

// extend enum using spread
enum AdvEvents {
    ...BasicEvents,
    Pause = "Pause",
    Resume = "Resume"
};
Awaiting More Feedback Suggestion

Most helpful comment

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.

All 68 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:

const enum BasicEvents {
  Start = 'Start',
  Finish = 'Finish'
}
const enum AdvEvents {
  Pause = 'Pause',
  Resume = 'Resume'
}
type Events = BasicEvents | AdvEvents;

let e: Events = AdvEvents.Pause;

Downside is you can't use Events.Pause; you have to use AdvEvents.Pause. If you're using const enums, this is probably ok. Otherwise, it might not be sufficient for your use case.

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.

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];

type sometype<T extends AdvEvents> =
    T extends typeof AdvEvents.Start ? 'Some String' :
    T extends typeof AdvEvents.Finish ? 'Some Other String' :
    T extends typeof AdvEvents.Pause ? 'Abc' :
    T extends typeof AdvEvents.Resume ? 'Xyz' : never;
type r = sometype<typeof AdvEvents.Finish>;

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

obrazek

+1
Currently using a workaround, but this should be a native enum feature.

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 AdvEvents to be assignable to BasicEvents? (As is, for example, the case with extends for classes.)

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 extends. BasicEvents should be assignable to AdvEvents, not the other way around. Normal extends refines another type to be more specific, and in this case we want to broaden the other type to add more values, so any custom syntax for this should probably not use the extends keyword, or at least not use the syntax enum A extends B {.

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.

BasicEvents should be assignable to AdvEvents, not the other way around.

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 https://github.com/Microsoft/TypeScript/issues/17592#issuecomment-331491147 , you can do a little better by also defining Events in the value space:

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 Events.Start is correctly interpreted as BasicEvents.Start in the type system, so exhaustiveness checking and discriminated union refinement seem to work fine. The main thing missing is that you can't use Events.Pause as a type literal; you need AdvEvents.Pause. You can use typeof Events.Pause and it resolves to AdvEvents.Pause, though people on my team have been confused by that sort of pattern and I think in practice I'd encourage AdvEvents.Pause when using it as a type.

(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?

type BEs = "Start" | "Finish";

type AEs = BEs | "Pause" | "Resume";

let example: AEs = "Finish"; // there is even autocompletion

So, the solution to our problems could be this?

const BasicEvents = {
    Start: "Start",
    Finish: "Finish"
};
// { Start: string, Finish: string };


const BasicEvents = {
    Start: "Start",
    Finish: "Finish"
} as const
// { Start: 'Start', Finish: 'Finish' };

https://github.com/Microsoft/TypeScript/pull/29510

Extending enums should be a core feature of TypeScript. Just sayin'

@wottpal Repeating my question from earlier:

If [enums can be extended], then how well does that mesh with the fact that enum types are meant to be final and not possible to extend?

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 extends here in effect has more of a _copying_ semantics (of the enum values from A into B)? Then, yes, the switches come out OK.

However, there is _some_ expectation in there that still seems broken to me. As a way to try and nail it down: with classes, extends does _not_ convey copying semantics — fields and methods do not get copied into the extending subclass; instead, they are just made available via the prototype chain. There is only ever one field or method, in the superclass.

Because of this, if class B extends A, we are guaranteed that B is assignable to A, and so for example let a: A = new B(); would be perfectly fine.

But with enums and extends, we wouldn't be able to do let a: A = B.b;, because there is no such corresponding guarantee. Which is what feels odd to me; extends here conveys a certain set of assumptions about what can be done, and they are not met with enums.

Then just calling it expands or clones? 🤷‍♂️
From a users perspective it just feels odd that something that basic is not straightforward to achieve.

If the reasonable semantics requires a whole new keyword (without much of prior art in other languages), why not instead re-use the spread syntax (...) as suggested in OP and this comment?

+1 I hope this will be added to the default enumeration feature. :)

Does anyone know any elegant solutions??? 🧐

If the reasonable semantics requires a whole new keyword (without much of prior art in other languages), why not instead re-use the spread syntax (...) as suggested in OP and this comment?

Yes, after thinking about it a bit more I think this solution would be good.

After reading this whole issue thread, it seems that there is a broad agreement that re-using the spread operator solves the issue, and addresses all of the concerns people have raised about making the syntax confusing/unintuitive.

// extend enum using spread
enum AdvancedEvents {
    ...BasicEvents,
    Pause = "Pause",
    Resume = "Resume"
};

Does this issue really still need the "Awaiting More Feedback" label at this point, @RyanCavanaugh ?

The feature wanted +1

Do we have any news on this issue ? It feels really useful to have the spreading operator implemented for enums.

Especially for use cases that involve metaprogramming the ability to alias and extend enums is somewhere between a must-have and a nice-to-have. There's no way currently to take one enum and export it in another name - unless you resort to one of the workarounds mentioned above.

@m93a Ah, so you mean that extends here in effect has more of a _copying_ semantics (of the enum values from A into B)? Then, yes, the switches come out OK.

However, there is _some_ expectation in there that still seems broken to me. As a way to try and nail it down: with classes, extends does _not_ convey copying semantics — fields and methods do not get copied into the extending subclass; instead, they are just made available via the prototype chain. There is only ever one field or method, in the superclass.

Because of this, if class B extends A, we are guaranteed that B is assignable to A, and so for example let a: A = new B(); would be perfectly fine.

But with enums and extends, we wouldn't be able to do let a: A = B.b;, because there is no such corresponding guarantee. Which is what feels odd to me; extends here conveys a certain set of assumptions about what can be done, and they are not met with enums.

@masak I think you're close to correct but you made one small assumption that is incorrect. B is assignable to A in the event of enum B extends A as "assignable" means that all values provided by A are available in B. When you said that let a: A = B.b you are assuming that values in B must be available in A, which is not the same as the values being assignable to A. let a: A = B.a IS correct because B is assignable to A.

This is evident using classes like in the following example:

class A {
 a() {}
}

class B extends A {
 b() {}
}

let a: A = new B();

a.a();  // valid
a.b();  // invalid via type system since `a` is typed as `A`

TypeScript Playground Link

invalid access

Long story short, I believe extends IS the correct terminology as that is exactly what is being done. In the enum B extends A example you can ALWAYS expect a B enum to contain all the possible values of the A enum, because B is a "subclass" (subenum? maybe there is a better word for this) of A and thus assignable to A.

So I don't think we need a new keyword, I think we should use extends AND I think this should be natively a part of TypeScript :D

@julian-sf I think I agree with every thing you wrote...

...but... :slightly_smiling_face:

as I problematized here, what about this situation?

// example from OP
enum BasicEvents {
    Start = "Start",
    Finish = "Finish"
};

// extend enum using "extends" keyword
enum AdvEvents extends BasicEvents {
    Pause = "Pause",
    Resume = "Resume"
};

Given that Pause is an _instance_ of AdvEvents and AdvEvents _extends_ BasicEvents, would you also expect Pause to be an _instance_ of BasicEvents? (Because that seems to follow from how the instance/inheritance relations usually interact.)

On the other hand, the core value proposition of enums (IMHO) is that they are _closed_/"final" (as in, non-extensible) so that something like a switch statement can assume totality. (And so AdvEvents being able to _extend_ what it means to be a BasicEvent violates some kind of Least Surprise for enums.)

I don't think that you can more than two of the following three properties:

  • Enums being closed/final/predictably total
  • An extends relation between two enum declarations
  • The (reasonable) assumption that if b is an instance of B and B extends A, then b is an instance of A

@masak I understand and agree with the closed principal of enums (at runtime). But compile time extension would not violate the closed principle at runtime, as they would all be defined and constructed by the compiler.

The (reasonable) assumption that if b is an instance of B and B extends A, then b is an instance of A

I think this reasoning is kind of misleading as the instance/class dichotomy is not really assignable to enum. Enums are not classes and they do not have instances. I do think however, that they can be extensible, if done properly. Think of enums more like sets. In this example, B is a superset of A. Therefore it is reasonable to assume that any value in A is present in B, but that only SOME values of B will be present in A.

I understand where the concern comes from...though. And I'm not sure what to do about that. A good example of an issue with enum extension:

const enum A { a = 'a' }
const enum B extends A { b = 'b' }

const foo = (a: A) => console.log(a);
const bar = (b: B) => foo(b);

bar(B.a); // 'a'
bar(B.b); // uh-oh, b doesn't exist on A, so foo would get unexpected behavior

// HOWEVER, this would work just fine...

const baz = (a: A) => bar(a);

baz(A.a); // 'a'
baz(B.a); // 'a'
baz(B.b); // compiler error as expected...

In this case, enums behave quite differently than classes. If these were classes, you would expect to be able to cast B to A quite easily, but that clearly won't work here. I don't necessarily think this is BAD, I think it should just be accounted for. IE, you can't scope an enum type upwards in its inheritance tree like a class. This could be accounted for with a compiler error along the lines of "cannot assign superset enum B to A, as not all values of B are present in A".

@julian-sf

I think this reasoning is kind of misleading as the instance/class dichotomy is not really assignable to enum. Enums are not classes and they do not have instances.

You're absolutely right, on the face of it.

  • Seen as an independent language construct, an enumeration has _members_, not instances. The term "member" is used both by the Handbook and the Language Specification. (C# and Python similarly use the term "member". Java uses "enum constant", because "member" has an overloaded meaning in Java.)
  • Seen from a compiled-code perspective, an enum has _properties_, mapping both ways — names-to-values and values-to-names. Again, not instances.

Thinking about this, I realize that I'm colored a little bit by Java's take on enums. In Java, enum values are literally instances of their enum type. Implementation-wise, an enum is a class extending the Enum class. (You're not allowed to do it manually, you have to go through the enum keyword, but that's what happens under the hood.) The nice thing about this is that enums get all the conveniences classes do: they can have fields, constructors, methods... In this appproach, enum members _are_ instances. (The JLS says as much.)

Note that I'm not proposing any changes to TypeScript enum semantics. In particular, I'm not saying TypeScript should change to using Java's model for enums. I _am_ saying that it's instructive/insightful to overlay a class/instance "understanding" on top of enums/enum members. Not "an enum _is_ a class" or "an enum member _is_ an instance"... but there are similarities that carry over.

What similarities? First and foremost, type membership.

enum Foo { A, B, C }
enum Bar { X, Y, Z }

let foo: Foo = Foo.C;
foo = Bar.Z;

The last line doesn't typecheck, because Bar.Z is not a Foo. Again, this is not _actually_ classes and instances, but it can be _understood_ using the same model, as if Foo and Bar were classes and the six members were their respective instances.

(We'll ignore for the purposes of this argument the fact that let foo: Foo = 2; is also semantically legal, and that in general, number values are assignable to variables of enum type.)

Enums have the additional property that they are _closed_ — sorry, I don't know a better term for this — once you define them, you cannot extend them. Specifically, the members listed inside of the enum declaration are the _only_ things that typematch against the enum type. ("Closed" as in "closed-world hypothesis".) This is a great property because you can verify with total certainty that all the cases in a switch statement on your enum have been covered.

With extends on enums, this property goes out the window.

You write,

I understand and agree with the closed principal of enums (at runtime). But compile time extension would not violate the closed principle at runtime, as they would all be defined and constructed by the compiler.

I don't think that's true, because it assumes that any code that extends your enum is in your project. But a third-party module can extend your enum, and suddenly there are _new_ enum members that are also assignable to your enum, outside of the control of code that you compile. Essentially, enums would no longer be closed, not even at compile-time.

I still feel I'm somewhat clumsy in expressing exactly what I mean, but I believe it's important: extends on enum would break one of the most precious features of enums, the fact that they're closed. Please count how many languages absolutely _forbid_ extending/subclassing an enum, for this very reason.

I thought about workarounds a little more, and working off of #17592 (comment) , you can do a little better by also defining Events in the value space:

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 Events.Start is correctly interpreted as BasicEvents.Start in the type system, so exhaustiveness checking and discriminated union refinement seem to work fine. The main thing missing is that you can't use Events.Pause as a type literal; you need AdvEvents.Pause. You _can_ use typeof Events.Pause and it resolves to AdvEvents.Pause, though people on my team have been confused by that sort of pattern and I think in practice I'd encourage AdvEvents.Pause when using it as a type.

(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.)

I think this is the neatest solution at hand, right now.

Thank you @alangpierce :+1:

any update on this?

@sdwvit I'm not one of the core people, but from my vantage point, the following syntax proposal (from OP, but re-suggested twice after that) would make everyone happy, without any known issues:

// extend enum using spread
enum AdvEvents {
    ...BasicEvents,
    Pause = "Pause",
    Resume = "Resume"
};

It would make _me_ happy, because it would mean implementing this seemingly useful "duplicate all the members in this other enum" feature _without_ using extends, which I consider to be problematic for reasons I've stated. The ... syntax avoids these issues by copying, not extending.

The issue is still marked as "Awaiting More Feedback", and I respect the core members' right to keep it in that category for as long as they feel is necessary. But also, there's nothing to stop anyone from implementing the above and submitting it as a PR.

@masak thank you for response. I now have to go through all the discussion history. Will get back to you after :)

I would absolutely love to see this happen and would absolutely love to attempt to implement this myself. However we still need to define behaviors for all enums. This all works well for string-based enums, but what about vanilla numeric enums. How does extending/copying work here?

  • I assume we will only want to allow extending an enum with a "same type" enum (numeric extends numeric, string extends string). Heterogeneous enums are technically supported so I suppose we should keep that support.

  • Should we allow extending from multiple enums? Should they all have mutually exclusive values? Or will we allow overlapping values? Priority based on lexical order?

  • Can the extending enums override values of the extended enums?

  • Must the extended enums appear that the start of the list of values or can it be in any order? I assume later defined values have higher priority?

  • I assume implicit numeric values will continue 1 after the max value of the extended numeric enums.

  • Special considerations for bit-masks?

etc. etc.

@JeffreyMercado These are good questions, and appropriate for one who hopes to attempt an implementation. :smile:

Below are my answers, guided by a "conservative" design approach (as in "let's make design decisions that disallow the cases we're not sure about, rather than making choices now that are hard to change later while staying backwards compatible").

  • I assume we will only want to allow extending an enum with a "same type" enum (numeric extends numeric, string extends string)

I assume so too. The resulting enum of mixed type doesn't seem super-useful.

  • Should we allow extending from multiple enums? Should they all have mutually exclusive values? Or will we allow overlapping values? Priority based on lexical order?

Since it's copying semantics we're talking about, duplicating multiple enums seems "more ok" than Multiple Inheritance à la C++. I don't immediately see a problem with it, especially if we keep building on the analogy of object spread: let newEnum = { ...enumA, ...enumB };

Should all the members have mutually exclusive values? The conservative thing would be to say "yes". Again, the analogy of object spread provides us with an alternative semantics: last one wins.

I can't think of any use cases where I would appreciate being able to override enum values. But that might just be a lack of imagination on my part. The conservative approach of disallowing collisions has the pleasing properties that it's easy to explain/internalize, and at least in theory it might expose real design errors (in fresh code, or code that's being maintained).

  • Can the extending enums override values of the extended enums?

I think the answer, and the reasoning, are much the same in this case as in the previous case.

  • Must the extended enums appear that the start of the list of values or can it be in any order? I assume later defined values have higher priority?

I was going to say first that this only matters if we go with the "last one wins" semantics of overriding.

But on second thought, both under "no collisions" and "last one wins", I find it weird to even _want_ to put enum member declarations before enum spreads in the list. Like, what intent is being communicated by doing so? The spreads are a little like "imports", and these conventionally go at the top.

I don't necessarily want to forbid putting enum spreads after enum member declarations (although I think I would be fine with it being disallowed in the grammar). If it ends up being allowed, it's definitely something that linters and community convention could point out as avoidable. There's just no compelling reason to do so.

  • I assume implicit numeric values will continue 1 after the max value of the extended numeric enums.

Maybe the conservative thing to do is to require an explicit value for the first member after a spread.

  • Special considerations for bit-masks?

I think that would be covered by the above rule.

I was able to do something reasonable by combining enums, interfaces, and immutable objects.

export enum Unit {
    SECONDS,
    MINUTES,
    HOURS,
    DAYS,
    WEEKS,
    MONTHS,
    YEARS,
    DECADES,
    CENTURIES,
    MILLENNIA
}

interface Labels {
    SINGULAR: Record<Unit, string>
    PLURAL: Record<Unit, string>
    LAST: string;
    DELIM: string;
    NOW: string;
}

export const EnglishLabels: Labels = {
    SINGULAR: {
        [Unit.SECONDS]: ' second',
        [Unit.MINUTES]: ' minute',
        [Unit.HOURS]: ' hour',
        [Unit.DAYS]: ' day',
        [Unit.WEEKS]: ' week',
        [Unit.MONTHS]: ' month',
        [Unit.YEARS]: ' year',
        [Unit.DECADES]: ' decade',
        [Unit.CENTURIES]: ' century',
        [Unit.MILLENNIA]: ' millennium'
    },
    PLURAL: {
        [Unit.SECONDS]: ' seconds',
        [Unit.MINUTES]: ' minutes',
        [Unit.HOURS]: ' hours',
        [Unit.DAYS]: ' days',
        [Unit.WEEKS]: ' weeks',
        [Unit.MONTHS]: ' months',
        [Unit.YEARS]: ' years',
        [Unit.DECADES]: ' decades',
        [Unit.CENTURIES]: ' centuries',
        [Unit.MILLENNIA]: ' millennia'
    },
    LAST: ' and ',
    DELIM: ', ',
    NOW: ''
}

@illeatmyhat That's a nice use of enums, but... I fail to see how it counts as extending an existing enum. What you're doing is _using_ the enum.

(Also, unlike with enums and switch statements, it seems that in your example you have no totality checking; someone who added an enum member later might easily forgot to add a corresponding key in the SINGULAR and PLURAL record in all the instances of Label.)

@masak

someone who added an enum member later might easily forgot to add a corresponding key in the SINGULAR and PLURAL record in all the instances of Label.)

At least in my environment, it throws an error when an enum member is missing from either SINGULAR or PLURAL. The Record type does its job, I guess.

While the documentation for TS is good, I feel that there aren't many examples of how to combine many features together in a nontrivial way. enum inheritance was the first thing I looked up when I tried to solve internationalization problems, leading to this thread. The approach turned out to be wrong anyways, which is why I wrote this post.

@illeatmyhat

At least in my environment, it throws an error when an enum member is missing from either SINGULAR or PLURAL. The Record type does its job, I guess.

Oh! TIL. And yes, that does make it a lot more interesting. I see what you mean about initially reaching for enum inheritance and eventually landing on your pattern. That might not even be an isolated thing; "X/Y problems" are a real thing. More people might start with the thought "I want to extend MyEnum", but end up using Record<MyEnum, string> like you did.

Reply to @masak:

With extends on enums, this property goes out the window.

You write,

@julian-sf: I understand and agree with the closed principal of enums (at runtime). But compile time extension would not violate the closed principle at runtime, as they would all be defined and constructed by the compiler.

I don't think that's true, because it assumes that any code that extends your enum is in your project. But a third-party module can extend your enum, and suddenly there are new enum members that are also assignable to your enum, outside of the control of code that you compile. Essentially, enums would no longer be closed, not even at compile-time.

The more I think about this, you're completely right. Enums should be closed. I really like the idea of "composing" enums as I think this is really the heart of the matter we want here 🥳.

I think this notation summarizes the concept of "stitching together" two separate enums quite elegantly:

enum ComposedEnum = { ...EnumA, ...EnumB }

So consider that my resignation on using the term extends 😆


Comments on @masak's answers to @JeffreyMercado's questions:

  • I assume we will only want to allow extending an enum with a "same type" enum (numeric extends numeric, string extends string). Heterogeneous enums are technically supported so I suppose we should keep that support.

I assume so too. The resulting enum of mixed type doesn't seem super-useful.

Whilst I agree that it's not useful, we SHOULD probably keep heterogeneous support for enums here. I think a linter warning would be useful here, but I don't think TS should get in the way of that. I can think of a contrived use case which is, I'm building an enum for interactions with a very poorly designed API that takes flags that are a mix of numbers and strings. Contrived, I know, but since it's allowed elsewhere, I don't think we should disallow it here.

Maybe just strong encouragement not to?

  • Should we allow extending from multiple enums? Should they all have mutually exclusive values? Or will we allow overlapping values? Priority based on lexical order?

Since it's copying semantics we're talking about, duplicating multiple enums seems "more ok" than Multiple Inheritance à la C++. I don't immediately see a problem with it, especially if we keep building on the analogy of object spread: let newEnum = { ...enumA, ...enumB };

100% agree

  • Should all the members have mutually exclusive values?

The conservative thing would be to say "yes". Again, the analogy of object spread provides us with an alternative semantics: last one wins.

I'm torn here. While I agree it's "best practice" to enforce mutual exclusivity of values, is it correct? It is directly contradictory to commonly-known spread semantics. One the one hand, I like the idea of enforcing mutually exclusive values, on the other hand, it breaks a lot of assumptions about how spread semantics should work. Are there any downsides to following normal spreading rules with "last one wins"? It seems like it's easier on implementation (as the underlying object is just a map anyway). But it also seems to align with common expectations. I'm leaning towards being less surprising.

There may also be good examples for wanting to override a value (although I have no idea what those would be).

But on second thought, both under "no collisions" and "last one wins", I find it weird to even want to put enum member declarations before enum spreads in the list. Like, what intent is being communicated by doing so? The spreads are a little like "imports", and these conventionally go at the top.

Well that depends, if we are following spread semantics, then it shouldn't matter what the order is. Honestly, even if we are enforcing mutually exclusive values the order wouldn't really matter, right? A collision would be an error at that point, regardless of order.

  • I assume implicit numeric values will continue 1 after the max value of the extended numeric enums.

Maybe the conservative thing to do is to require an explicit value for the first member after a spread.

I agree. If you spread an enum, TS should just enforce explicit values for additional members.

@julian-sf

So consider that my resignation on using the term extends 😆

:+1: The Society for the Preservation of Sum Types cheers from the sidelines.

But on second thought, both under "no collisions" and "last one wins", I find it weird to even want to put enum member declarations before enum spreads in the list. Like, what intent is being communicated by doing so? The spreads are a little like "imports", and these conventionally go at the top.

Well that depends, if we are following spread semantics, then it shouldn't matter what the order is. Honestly, even if we are enforcing mutually exclusive values the order wouldn't really matter, right? A collision would be an error at that point, regardless of order.

I'm saying "there's no good reason to place spreads after normal member declarations"; you're saying "under the appropriate restrictions, placing them before or after makes no difference". Both of these things can be true at the same time.

The main difference in outcome seems to fall on a spectrum of allowing or disallowing spreads before normal members. It could be syntactically disallowed; it could produce a linter warning; or it could be completely fine in all regards. If the order makes no semantic difference, then it comes down to making the enum spread feature follow the principle of Least Surprise, easy to use, and easy to teach/explain.

Using the spread operator falls in the wider use of shallow copy throughout JS and TypeScript. It's certainly the more widely used and easier to understand method than using extends, which implies a direct relationship. Creating an enum through composition would be the easier solution to consume.

Some of the work around suggestions, while valid and usable, add a lot more boilerplate code to achieve the same desired outcome. Given the final and immutable nature of an enum, creating additional enums through composition would be desirable, to maintain the properties that are consistent with other languages.

Its's just a shame that 3 years on this conversation is still going.

@jmitchell38488 I would drop a like to your comment, but your last sentence changed my mind. This is a needed discussion, since proposed solution would work, but it also implies the possibility of extending classes and interfaces this way. It is a big change which may scare some c++-like languages programmers from using typescript, since you basically end up with 2 ways of doing the same thing (class A extends B and class A { ...(class B {}) }). I think, both ways can be supported, but then we need extend for enums as well for consistency.

@masak wdyt? ^

@sdwvit I'm not taking about changing the behaviour for creating classes and interfaces, I'm talking specifically about enums and creating them through composition. They're immutable final types, so we should not be able to extend in the typical inheritance fashion.

Given the nature of JS and the final transpiled value, there's no reason why composition can't be achieved. Sure would make working with enums more attractive.

@masak wdyt? ^

Hm. I think language consistency is a laudable goal, and therefore it's not _a priori_ wrong to ask for a similar ... feature in classes and interfaces. But I think the case is weaker or nonexistent there, and for two reasons: (a) classes and interfaces already have an extension mechanism, and adding a second one provides little additional value (whereas providing one for enums would cover a use case that people have been coming back to this issue for for years); (b) adding new syntax and semantics to classes has a much higher bar for approval, since classes are in some sense from the EcmaScript specification. (TypeScript is older than ES6, but there's been an active effort to have the former stay close to the latter. That includes not introducing extra features on top.)

I think this thread has been open for a long time simply because it represents a worthy feature which would cover actual use cases, but it's yet to get a PR made for it. Making such a PR takes time and effort, more so than just saying you want the feature. :wink:

Is anyone working on this feature?

Is anyone working on this feature?

My guess is no, since we haven't even finished the discussion on it, haha!

I do feel like we've come closer to a consensus on what this would look like. Since this would be a language addition, it would probably require a "champion" to push this proposal forward. I think someone from the TS team would need to come in and move the issue from "Awaiting feedback" to "Awaiting proposal" (or something similar).

I am working on a prototype of it. Though I haven't gotten very far due to lack of time and unfamiliarity with the structure of the code. I do want to see this through and if there's no other movement, I'll continue on when I can.

Would also love this feature. 37 months have passed, hopefully progress is made soon

Notes from recent meeting:

  • We like the spread syntax, because extends implies a subtype, whereas “extending” an enum creates a supertype.

    enum BasicEvents {
     Start = "Start",
     Finish = "Finish"
    }
    enum AdvEvents {
     ...BasicEvents,
     Pause = "Pause",
     Resume = "Resume"
    }
    
  • The thought is that AdvEvents.Start would resolve to the same type identity as BasicEvents.Start. This has the implications that the types BasicEvents.Start and AdvEvents.Start would be assignable to each other, and the type BasicEvents would be assignable to AdvEvents. Hopefully this makes intuitive sense, but it’s important to note that this means the spread is not just a syntactic shortcut—if we expand the spread into what it looks like it means:

    enum BasicEvents {
     Start = "Start",
     Finish = "Finish"
    }
    enum AdvEvents {
     Start = "Start",
     Finish = "Finish",
     Pause = "Pause",
     Resume = "Resume"
    }
    

    this has different behavior—here, BasicEvents.Start and AdvEvents.Start are _not_ assignable to each other, due to the opaque quality of string enums.

    Another minor consequence of this implementation is that AdvEvents.Start would probably be serialized as BasicEvents.Start in quick info and declaration emit (at least where there’s no syntactic context to link the member to AdvEvents—hovering over the literal expression AdvEvents.Start could plausibly result in quick info that says AdvEvents.Start, but it might be more clear to display BasicEvents.Start nonetheless).

  • This would only be allowed in string enums.

I'd like to try on this one.

To clarify: This is not approved for implementation.

There are two possible behaviors here, and they both seem bad.

Option 1: It's Actually Sugar

If spread really means the same as copying down the spreaded enum's members, then there will be a big surprise when people try to use the extending enum's value as if it were the extended enum's value and it doesn't work.

Option 2: It's Not Actually Sugar

The preferred option would be that spread works more like @aj-r's union type suggestion near the top of the thread. If that's the behavior people already want, then the existing options on the table seem strictly preferable for understandability's sake. Otherwise we're creating a new kind of string enum that doesn't behave like any other string enum, which is weird and seems to undermine the "simpleness" of the suggestion here.

Which behavior do people want, and why?

I don't want option 1, because it leads to big surprises.

I do want option 2, but I would love to have enough syntax support to overcome the downside @aj-r mentioned, so I could write let e: Events = Events.Pause; from his example. The downside isn't terrible, but it is a place where the extended enum can't hide the implementation; so it's kind of gross.

I also think option 1 is a bad idea. I searched for references to this issue at my company and found two code reviews where it was linked, and in both cases (and in my personal experience), there's a clear need for elements of the smaller enum to be assignable to the larger enum type. I especially worry about one person introducing ... thinking it behaves like option 2, and then the next person getting really confused (or resorting to hacks like as unknown as Events.Pause) when more complex cases don't work.

There are already a lot of ways to try to get the behavior of option 2: plenty of snippets in this thread, plus various approaches involving string literal unions. My big worry with implementing option 1 is that it effectively introduces another wrong way to get option 2, and thus leads to more troubleshooting and frustration for people learning this part of TypeScript.

Which behavior do people want, and why?

Since code speaks louder than words, and using the example from the OP:

const BasicEvents = {
    Start: "Start",
    Finish: "Finish"
};
enum AdvEvents {
    ...BasicEvents,
    Pause = "Pause", // We added a new field
    Finish = "Finish2" // Oops, we actually modified a field in the parent enum
};
// The TypeScript compiler should refuse to compile this code
// But after removing the "Finish2" line,
// the TypeScript compiler should successfully handle it as one would normally expect with the spread operator

This example showcases option 2, right? If so, then I desperately want option 2.

Otherwise we're creating a new kind of string enum that doesn't behave like any other string enum, which is weird and seems to undermine the "simpleness" of the suggestion here

I agree that option 2 is a little unsettling and it may be best to step back and think of alternatives. Here's an exploration of how it could be done without adding any new syntax or behavior to today's enums:

I think my suggestion in https://github.com/microsoft/TypeScript/issues/17592#issuecomment-449440944 gets the closest these days and might be something to work off of:

type Events = BasicEvents | AdvEvents;
const Events = {...BasicEvents, ...AdvEvents};

I see two main problems with that approach:

  • It's really confusing for someone learning TypeScript; if you don't have a solid grasp on the type space vs the value space, it looks like it's overwriting a type with a constant.
  • It incomplete since it doesn't allow you to use Events.Pause as a type.

I previously suggested https://github.com/microsoft/TypeScript/issues/29130 that would (among other things) address the second bullet point, and I think it could still be valuable, though it certainly would add a lot of subtlety to how names work.

One syntax idea that I think would address both points is an alternative const syntax that also declares a type:

// Declares a value and a type at the same time with the same name (just like `enum` and `class` already do).
// Requires the right-hand side to be either a unit type or an object where all values are unit types.
// The JS emit would just take out the word "type" and leave everything else.
const type Events = {...BasicEvents, ...AdvEvents};
...
const e: Events.Pause = Events.Pause;
...
// The syntax could also make this pattern more ergonomic.
const type INACCESSIBLE = "INACCESSIBLE";
const response: {name: string, favoriteColor: string} | INACCESSIBLE = INACCESSIBLE;

This would get closer to a world where enums aren't necessary at all. (To me, enums have always felt like they go against the TS design goals since they're an expression-level syntax with non-trivial emit behavior and nominal by default.) The const type declaration syntax would also let you create a structurally-typed string enum like this:

const type BasicEvents = {
  Start: 'Start',
  Finish: 'Finish',
};  // "as const" would be implicit for "const type" declarations.

Admittedly, the way this would need to work is that the type BasicEvents is shorthand for typeof BasicEvents[keyof typeof BasicEvents], which might be too focused for a generically-named syntax like const type. Maybe a different keyword would be better. Too bad const enum is already taken 😛.

From there, I think the only gap between (string) enums and object literals would be nominal typing. That possibly could be solved using a syntax like as unique or const unique type that basically opts into enum-like nominal typing behavior for these object types, and ideally for regular string constants as well. That would also give a clear choice between option 1 and option 2 when defining Events: you use unique when you intend Events to be a completely distinct type (option 1), and you omit unique when you want the assignability between Events and BasicEvents (option 2).

With const type and const unique type, there would be a way to cleanly union existing enums, and also a way to express enums as a natural combination of TS features rather than a one-off.

What's happening here? 😅

wow from 2017 🤪, what more feedback do you need?

what more feedback do you need?

Right here??? We don't just implement suggestions because they're old!

what more feedback do you need?

Right here??? We don't just implement suggestions because they're old!

Yes. I was not sure which way it was.

Reading again and seeing also #40998 I think that is the best way... the emuns are objects and I think the spread is easier to merge/extend enums.

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:

  1. Repeating declarations (both type and const for a new enum) wasn't a big problem and didn't damage readability much.
  2. In my case, enum represented different actions for a certain algorithm, and there were different sets of actions for different situations in this algorithm. Using type hierarchy allowed me to verify that certain actions couldn't happen in compile time: this was the point of the whole thing and turned out to be quite useful.
  3. The code I wrote was an internal library, and while the distinction between different enum types was important inside the library, it didn't matter to outside users of this library. Using this approach, I was able to export only one type that was the sum type of all different enums inside, and hide the implementation detail.
  4. Unfortunately, I wasn't able to figure out an idiomatic and easy to read way to parse values of the sum type automatically from the type declarations. (In my case, different algorithm steps I mentioned were loaded from SQL database at runtime). It wasn't a huge issue (writing the parsing code manually was straightforward enough), but it would be nice if the implementation of enum inheritance would pay some attention to this issue.

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 context

I 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 setStatus that takes a subset of these status. Since the feature is not available, I considered duplicating the enum that way:

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.
After scrolling all the way down, I didn't found anyone (or maybe I missed something) that suggests this use case.

In my use case, I don't want to "extend" from an enum because there's no reason that EntityStatus would be an extension of RequestedEntityStatus. I'd prefer to be able to "Pick" from the more-generic enum.

My Proposal

I 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

I'd like to relax the constraints on enum types to behave more like unions ('a' | 'b') than opaque structures.

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:
Given the following enums:

enum A { a = 'a', b = 'b' }
enum B { a = 'a' , c = 'c' }
enum C { ...A, c = 'c' }

And three variables a:A, b:B and c:C

  • c = a should work, because A is just a subset of C, so every value of A is a valid value of C
  • c = b should work, since B is just 'a' | 'c' which are both valid values of C
  • b = a could work if a is known to be different from 'b' (which would equal to the type 'a' only)
  • a = b, likewise, could work if b is known to be different from 'c'
  • b = c could work if c is known to be different from 'b' (which would equal to 'a'|'c', which is exactly what B is)

or maybe we should need an explicit cast on the right-hand sides, as for the equality comparison ?

About enum members conflicts

I'm not a fan of the "last wins" rule, even if it feels natural with the spread operator.
I'd say that, the compiler should return an error if the either a "key" or a "value" of the enum is duplicated, unless both key and value are identical:

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'

Closing

I find this proposal quite flexible and natural for TS devs to work with, while allowing more type safety (compared to as unknown as T) without actually changing what an enum is. It just introduces a new way to add members to enum, and a new way to compare enums to each others.
What do you think? Did I miss some obvious language architecture issue that makes this feature unachievable?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

wmaurer picture wmaurer  ·  3Comments

kyasbal-1994 picture kyasbal-1994  ·  3Comments

siddjain picture siddjain  ·  3Comments

fwanicka picture fwanicka  ·  3Comments

DanielRosenwasser picture DanielRosenwasser  ·  3Comments