microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.95k stars 12.47k forks source link

Extending string-based enums #17592

Open nomaed opened 7 years ago

nomaed commented 7 years ago

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"
};
nomaed commented 7 years ago

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"
};
aluanhaddad commented 7 years ago

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);
aj-r commented 7 years ago

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.

serhiipalash commented 6 years ago

We need this feature for strongly typed Redux reducers. Please add it in TypeScript.

aj-r commented 6 years ago

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];
Xenya0815 commented 6 years ago

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.

nOstap commented 6 years ago

Just use class instead of enum.

guptaamol commented 6 years ago

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.

cshaa commented 5 years ago

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

LukePetruzzi commented 5 years ago

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

masak commented 5 years ago

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?

alangpierce commented 5 years ago

@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 {.

masak commented 5 years ago

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.

masak commented 5 years ago

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.

alangpierce commented 5 years ago

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

CyberMew commented 5 years ago

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
ackvf commented 5 years ago

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

wottpal commented 5 years ago

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

masak commented 5 years ago

@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.

cshaa commented 5 years ago

@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
}
masak commented 5 years ago

@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.

wottpal commented 5 years ago

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

masak commented 5 years ago

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?

ninezero90hy commented 5 years ago

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

ninezero90hy commented 5 years ago

Does anyone know any elegant solutions??? 🧐

wottpal commented 5 years ago

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.

masonmark commented 5 years ago

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 ?

nighca commented 5 years ago

The feature wanted +1

arthur-caillaud commented 5 years ago

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

nadeesha commented 5 years ago

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.

julian-sf commented 5 years ago

@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

masak commented 5 years ago

@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:

julian-sf commented 5 years ago

@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".

masak commented 5 years ago

@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.

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.

masak commented 5 years ago

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.

MajidJafari commented 4 years ago

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:

sdwvit commented 4 years ago

any update on this?

masak commented 4 years ago

@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.

sdwvit commented 4 years ago

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

JeffreyMercado commented 4 years ago

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?

etc. etc.

masak commented 4 years ago

@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.

illeatmyhat commented 4 years ago

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: ''
}
masak commented 4 years ago

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

illeatmyhat commented 4 years ago

@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.

masak commented 4 years ago

@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.

julian-sf commented 4 years ago

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.

masak commented 4 years ago

@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.

jmitchell38488 commented 4 years ago

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.

sdwvit commented 4 years ago

@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? ^

jmitchell38488 commented 4 years ago

@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.