Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extending string-based enums #17592

Open
nomaed opened this issue Aug 3, 2017 · 86 comments
Open

Extending string-based enums #17592

nomaed opened this issue Aug 3, 2017 · 86 comments
Labels
Suggestion An idea for TypeScript Waiting for TC39 Unactionable until TC39 reaches some conclusion

Comments

@nomaed
Copy link

nomaed commented Aug 3, 2017

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"
};
@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Aug 3, 2017
@nomaed
Copy link
Author

nomaed commented Aug 3, 2017

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
Copy link
Contributor

aluanhaddad commented Aug 7, 2017

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
Copy link

aj-r commented Sep 22, 2017

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
Copy link

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

@aj-r
Copy link

aj-r commented Mar 25, 2018

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
Copy link

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
Copy link

nOstap commented Sep 4, 2018

Just use class instead of enum.

@jack-williams jack-williams mentioned this issue Oct 17, 2018
4 tasks
@guptaamol
Copy link

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
Copy link

cshaa commented Nov 29, 2018

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
Copy link

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

@masak
Copy link

masak commented Dec 21, 2018

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
Copy link
Contributor

@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
Copy link

masak commented Dec 21, 2018

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
Copy link

masak commented Dec 21, 2018

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
Copy link
Contributor

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

@CyberMew
Copy link

CyberMew commented Feb 19, 2019

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

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature and removed In Discussion Not yet reached consensus labels Feb 25, 2019
@ackvf
Copy link

ackvf commented Feb 27, 2019

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' };

#29510

@wottpal
Copy link

wottpal commented Mar 12, 2019

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

@masak
Copy link

masak commented Mar 12, 2019

@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
Copy link

cshaa commented Mar 13, 2019

@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
Copy link

masak commented Mar 13, 2019

@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
Copy link

wottpal commented Mar 13, 2019

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

@jeremyVignelles
Copy link

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?

@yazeedb
Copy link

yazeedb commented May 9, 2021

As a workaround, I'm trying Union Types with a guaranteed type property—much like Redux actions.

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 Exercise can't have a randomly difficulty...

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
  }
}

@john-larson
Copy link

Hi @RyanCavanaugh, is this feature on the roadmap?

@emil45
Copy link

emil45 commented Nov 7, 2021

Hello, any progress?

@DanielRosenwasser
Copy link
Member

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.

@DanielRosenwasser
Copy link
Member

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 enum E { ...F }, F is assignable to E?

If it's the latter, could we make a world where for each member of enum E { ...F }, the type of that member is a supertype the corresponding member of F?

@jeremyVignelles
Copy link

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 ({ OtherEnum.a, OtherEnum.b}) allows to create a subet of an enum.

Do you have a link to the JS proposal?

@whut
Copy link

whut commented Feb 11, 2022

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?

@nazarioa
Copy link

nazarioa commented Feb 17, 2022

for now what I have been doing

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.

Thank you!
For others... for now what I have been doing is this:

type Gender = 'male' | 'female' | 'pansexual' | ...
const Gender = {
  Male: 'male' as Gender,
  Female: 'female' as Gender,
  Pansexual: 'pansexual' as Gender,
...
}

this allows

const somonesGender: Gender = 'male' //  a "string" value coming from backend or from radio button that can be assigned and treated as "Gender"
const somonesElsesGender = Gender.Male  // this is allowed too and is treated as a "Gender"

@freakzlike
Copy link

@nazarioa

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

@whut
Copy link

whut commented Feb 22, 2022

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.

@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

@patriciavillela
Copy link

Please, @nazarioa and @freakzlike, refrain from two things:

  1. mixing up sexual orientation with gender. Pansexuality is a sexual orientation.
  2. limiting gender and sexual orientation options. Those are not clear cut and are based on the individual's experiences.

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.

@masak
Copy link

masak commented Apr 12, 2022

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 switch statements, for example. If the implementation of this issue affects that guarantee, we've all lost something valuable.

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 Gender enum, but it's possible to imagine the original author similarly objecting to their enum being extended.

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:

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.

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:

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.

I guess what I should really be doing is head over to the tc39 repository and defend JavaScript enums against the extends keyword. 😄 This seems to be the issue for doing that.

@nazarioa
Copy link

nazarioa commented Apr 21, 2022

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

@RyanCavanaugh RyanCavanaugh added Waiting for TC39 Unactionable until TC39 reaches some conclusion and removed Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Oct 27, 2022
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Oct 27, 2022

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 enum declaration form to JavaScript, and we'd like this to either proceed, or have the committee informally agree to indefinitely hold off on adding enum to JS in any form.

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 enum XY { ...X, three = "c", four = "d" } declaration

@whatwhywhenandwho
Copy link

A good alternative is...

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:

enum B extends A {}

is not viable. It's simple, elegant, and consistent with the rest of the TypeScript syntax.

@masak
Copy link

masak commented Dec 19, 2022

I don't get why:

enum B extends A {}

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 extends as a keyword for enums sets exactly the wrong expectations, which then cannot be upheld with enums. The enum feature implies "these are the enum values, and nothing else" (closed world). The extends keyword implies "we use something as a base, and then add some more stuff" (open world). The exhaustiveness check of switch is also based on the closed-world assumption. Allowing any kind of extends semantics breaks that assumption. (Now consider using extends on an enum, but in a third-party module you neither control or even know about.)

Seeing as how the main reason for having enums is to enforce the closed-world assumption and to allow for the exhaustiveness check in switch, I am vehemently, categorically opposed to using extends in the way you propose.

@alita-moore
Copy link

alita-moore commented Jun 24, 2023

what about enum B includes A { }? either way, are there plans to implement this functionality?

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

@masak
Copy link

masak commented Jun 25, 2023

...using extends as a keyword for enums sets exactly the wrong expectations, which then cannot be upheld with enums. The enum feature implies "these are the enum values, and nothing else" (closed world). The extends keyword implies "we use something as a base, and then add some more stuff" (open world). The exhaustiveness check of switch is also based on the closed-world assumption. Allowing any kind of extends semantics breaks that assumption. (Now consider using extends on an enum, but in a third-party module you neither control or even know about.)

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.

@MartinJohns MartinJohns mentioned this issue Jul 23, 2023
5 tasks
sophiamersmann added a commit to owid/owid-grapher that referenced this issue Nov 26, 2024
Refactors Grapher types by replacing enums with compile-time-only types.

I introduced `GrapherTabName` in the previous PR, which is either a `ChartTypeName` or `"WorldMap"` or `"Table"`. But enums currently [can't be extended or merged](microsoft/TypeScript#17592), which means that I had to copy-paste all chart type names into `GrapherTabName`. As a result, TypeScript doesn't do a good job of type narrowing, forcing me to write (unnecessary) type assertions in many places.

This PR refactors `ChartTypeName` and other tab-related types so they don't use enums. However, I still wanted the convenience of a single object to access chart types, for example. To get that, the types are inferred from const objects:

```ts
const GRAPHER_CHART_TYPES = {
    LineChart: "LineChart",
    ScatterPlot: "ScatterPlot"
} as const

type GrapherChartType = keyof typeof GRAPHER_CHART_TYPES
```

I first did it the other way around (first defining the types, then using them for the objects), but it turns out the way it's done now leads to better type narrowing. 

**Newly introduced types:**
- `GrapherMapType`: only "WorldMap"
- `GrapherChartType`: "LineChart", "ScatterPlot", etc. (without "WorldMap")
- `GrapherChartOrMapType`: `GrapherChartType` or `GrapherMapType`
- `GrapherTabName`: Internal tab names used in Grapher (a chart type name or "WorldMap" or "Table")
- `GrapherTabOption`: Grapher tab specified in the config that determines the default tab to show ("chart", "map" or "tab")
- `GrapherTabQueryParam`: Valid values for the `tab` query parameter in Grapher ("chart", "map", "line", "slope", etc.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Suggestion An idea for TypeScript Waiting for TC39 Unactionable until TC39 reaches some conclusion
Projects
None yet
Development

Successfully merging a pull request may close this issue.