Settings

Theme

An ode to TypeScript enums

blog.disintegrator.dev

57 points by disintegrator a year ago · 72 comments

Reader

spankalee a year ago

Const objects really are better than enums, in every way except declaration brevity.

They're erasable syntax, so they work in environments that just strip types. Their emit is just what you write without the types. They can be composed in a type-safe way with standard JS operations.

You can still write JS docs for values, deprecated the, mark them as internal, etc.

    type ValueOf<T> = T[keyof T];

    const Foo = {
      /**
       * A one digit
       * @deprecated
       */
      one: '1',
      two: '2',
      three: '3'
    } as const;
    type Foo = ValueOf<typeof Foo>;

    const Bar = {
      blue: 'blue',
    } as const;
    type Bar = ValueOf<typeof Bar>;

    // You can union enum objects:
    const FooOrBar = {...Foo, ...Bar};
    // And get union of their values:
    type FooOrBar = ValueOf<typeof FooOrBar>;

    const doSomething = (foo: Foo) => {}

    // You can reference values just like enums:
    doSomething(Foo.two);

    // You can also type-safely reference enum values by their
    // key name:
    doSomething(Foo['two']);

Given the TypeScript team's stance on new non-erasable syntax, I have to think this is how they would have gone if they had `as const` from the beginning. Ron Buckton of the TS team is championing an enum proposal for JS: https://github.com/rbuckton/proposal-enum Hopefully that goes somewhere and improves the declaration side of thigns too.
  • cies a year ago

    When I see this it makes me want to run for ReasonML/ReScript/Elm/PureScript.

    Sum types (without payloads on the instances they are effectively enums) should not require a evening filling ceremonial dance event to define.

    https://reasonml.github.io/

    https://rescript-lang.org/

    https://elm-lang.org/

    https://www.purescript.org/

    (any I forgot?)

    It's nice that TS is a strict super set of JS... But that's about the only reason TS is nice. Apart from that the "being a strict super set" hampers TS is a million and one ways.

    To me JS is too broken to fix with a strict super set.

    • int_19h a year ago

      TS does have sum types, so you can already do something like:

         type Color = "red" | "green";
      
      What GP is doing is some scaffolding on top to make the values more discoverable and allow associating arbitrary Color-specific metadata with them.
      • davorak a year ago

        I thought there were edge cases where sum types were not fully supported, nesting[1] is the example found first, but I thought there were others.

        [1] https://github.com/microsoft/TypeScript/issues/18758

        • int_19h a year ago

          Per the comments there, it still works if you use an explicit type guard function for checks, as opposed to checking the nested property directly, so I'd argue that the type itself is still supported in general, just not this particular way of testing for one of the options.

          • davorak a year ago

            Maybe I am doing something wrong but when I fill in a concrete value for `aOrB` I still get a type error in that example code:

            Playground Link: https://www.typescriptlang.org/play/?#code/C4TwDgpgBAglC8UDe...

            I tried with 5.8.2 and nightly and the results were the same.

            Interestingly, the playground reports aOrB(from github comment with concrete value) and aOrB2(modified) as the same type, `A | B`, but aOrB will give an error in the typeguarded if block but aOrB2 does not trigger an error. I do not know what is going on there either they do not really have the same type despite the playground reporting both as `A | B` or there is different bug going on.

            So the solution presented in github does not look like a full solution as is.

            • int_19h a year ago

              As far as I can tell, it's failing in the first conditional because it considers the type of `aOrB` to be `B` (rather than `A | B`) even before testing, based on it being initialized to B and then never assigned anything else. So when you apply the type guard, the resulting type of `aOrB` becomes, effectively, `A & B` (intersection), which of course doesn't have member `a`.

              It would make more sense for TS to treat the body of the if-statement as unreachable and give a warning based on that, but I guess they figured that this kind of thing - doing a type guard on a value that is already known to be of a different type - is a very narrow corner case that isn't worth improving diagnostics for.

              I'm more curious about why "smuggling" it through an array makes it work. In that case, the type of `aOrB2` remains `A | B` in the conditional, so everything is working as you expected, but I don't see the fundamental difference between this case and the previous one...

              • davorak a year ago

                > As far as I can tell, it's failing in the first conditional because it considers the type of `aOrB` to be `B`...

                The playground hover type annotation says aOrB is `A | B` at declaration and if you hover over aOrB in `if (hasTypeName(aOrB, "A"))` it produces `const aOrB: B`. Two types for 1 variable with no operations between. Not clear what operation is being performed on `aOrB`'s type that transforms it or if the playground type hover is just wrong.

                > It would make more sense for TS to treat the body of the if-statement as unreachable and give a warning based on that, but I guess they figured that this kind of thing - doing a type guard on a value that is already known to be of a different type - is a very narrow corner case that isn't worth improving diagnostics for.

                That does not seem to be the case, the type guard is not guarding what is in the if block, at least not consistently. It is not about the value being known at least, it is about the property being missing from what I can tell and the guards not being able to guard against it. If you have a top level `a` and `b` in both `A` and `B` there are no errors triggered:

                    type A = {
                        type: {
                            name: "A"
                        }
                        a: number,
                        b: undefined,
                    }
                
                    type B = {
                        type: {
                            name: "B"
                        }
                        b: number,
                        a: undefined,
                    }
                
                
                    const aOrB: A | B = {
                        type: {
                            name: "A"
                        },
                        a: 1,
                        b: undefined
                    };
                
                    // error as expect
                    if (aOrB.type.name === "B") {
                        console.log(aOrB.b) // Error
                    }
                
                    function hasTypeName<Name extends string>(a: { type: { name: string }}, name: Name): a is { type: { name: Name }} {
                        return a.type.name === name
                    }
                
                    if (hasTypeName(aOrB, "B")) {
                        console.log(aOrB.b) // no error
                    }
                
                    if (hasTypeName(aOrB, "A")) {
                        console.log(aOrB.a) // no error here as well
                    }
                
                The guards not working or premature type narrowing(the inability to set a variable to a type and have typescript treat it as that type with the above type annotations).
                • int_19h a year ago

                  > The playground hover type annotation says aOrB is `A | B` at declaration and if you hover over aOrB in `if (hasTypeName(aOrB, "A"))` it produces `const aOrB: B`. Two types for 1 variable with no operations between.

                  It's not that the variable has two different types. It's that the expression `aOrB` has a different type inside the condition. This is normal for TS - indeed, the very pattern of doing a check first and then magically getting a different type inside the body of the conditional hinges on this narrowing behavior. This particular case just looks a bit weird because there's no conditional, it's based solely on the assignment. You can see the same in code without any conditionals at all:

                    let foo: {foo: number} | {bar: string};
                    foo = {foo: 123};
                    foo; // if you hover over foo here, the type is narrowed.
                  
                  So, before it even gets to the type guard, it has already determined that the actual type of expression `aOrB` can only be `B`, and typed it as such. OTOH when a type guard is used, if it returns true, it knows that `aOrB` can only be `A`. To combine these two, it has to type it as `A & B`, which is what you see in the hover if you do it inside the body of the conditional. And the intersection type will only show the properties `A` and `B` have in common.

                  As for your new example, keep in mind that a missing property is not the same as `undefined` in TS (nor in JS itself, since there are ways to observe that difference). So the sum type must have both `a` and `b`, but either one can be set to `undefined` (but not omitted!) depending on `type`. If you remove `b: undefined` from the initializer of `aOrB`, you will see an error telling you that `b` is missing.

                  However, your example does not produce any error at the line with the comment that says "Error". Instead, you get a warning on the line above, specifically for this expression:

                     aOrB.type.name === "B"
                  
                  And if you look at the text of that warning, it basically says that `aOrB.type.name` is statically known to always be of type "A" at this point (since that is what was in your initializer, and TS did the requisite narrowing), and thus comparing it to "B" is pointless since it'll never be equal. All the property accesses for `a` and `b` work fine though since your sum type has both properties for both variants.
    • ninetyninenine a year ago

      TS sum types are actually more powerful thanks to 'as const'

      These are dependent types which none of the languages above can enable. Meaning the type system can actually read values in your code and create types from the code. This is not inferring the type, this is very different.

      For example:

         const PossibleStates = ["test", "me"] as const
      
         type SumTypeFromArray = (typeof PossibleStates)[number]
      
         let x: SumTypeFromArray = "this string triggers a type error as it is neither 'test' nor 'me'"
      
      So in TS you can actually loop through possible states while in ML style languages you would have to pattern match them individually.
    • pjmlp a year ago

      That single reason is all that matters, because it maps directly to what the platform actually understands, instead of adding another layer to debug.

    • hombre_fatal a year ago

      On the other hand, pasting the `type ValueOf<T> = T[keyof T];` idiom into your TS code so you can use it for your enums is a hell of a lot less ceremony than ditching TS for any of the languages you listed. Especially when you can still just us TS enums if you wish.

      And on top of that, each of them has a whole new collection of ceremonies you're going to have to learn.

      All for what, to avoid `as const`?

    • dkersten a year ago

      > (any I forgot?)

      Gleam has real sum types and can compile to JS.

      https://gleam.run/

    • spankalee a year ago

      > It's nice that TS is a strict super set of JS... But that's about the only reason TS is nice.

      I mean, yes, exactly?? That's TypeScript's entire reason for being, and it's no small thing.

      I use TypeScript where I would have used plain JavaScript. If I have a reasonable choice of an entirely different language - ie, I'm not targeting browsers or Node - then I would definitely consider that.

      I personally haven't seen that any compile-to-JS language is worth the interop tax with browsers or the JS ecosystem, and I've built very complex apps on GWT and used to be on the Dart team working on JS interop.

      • Freedom2 a year ago

        Civet (https://civet.dev) is probably my favorite one if I want something a bit fancier than Typescript, purely because it shares the same elements that you are as "opt-in" as much as you like, at least in my limited experience.

  • disintegratorOP a year ago

    This is spot on but the issue I called out in my post is that there’s nothing drawing devs to import the mapping. Like there’s the immediate convenience of passing a string literal to a field that’s a string union for instance. You’ve done a nice thing in your snippet and named the mapping and corresponding type the same but that’s also uncommon though I’m seeing it more nowadays. So as I see it it’s very possible to end up with a codebase that inconsistently uses the object mapping if that matters to you.

    • spankalee a year ago

      I don't know how important it is to bad use of the values directly, but that is also possible.

      You have to intersect every value with a brand, like:

          type Enum<T> = {
            [K in keyof T]: T[K] & {__brand: never};
          }
      
          const _Foo = {
            one: '1',
            two: '2',
            three: '3'
          } as const;
          const Foo = _Foo as Enum<typeof _Foo>;
          type Foo = ValueOf<typeof Foo>;
      
      And now, this will work:

          doSomething(Foo.two);
      
      But this will error:

          doSomething('2');
      • davorak a year ago

        Unlike the enum solution this is not nominal to my understanding.

            const t = ('2' as '2' & {__brand: never});
            doSomething(t);
        
        Does not trigger an error.

        So you can do something like

            const _Foo2 = {
              two: '2',
            } as const;
            const Foo2 = _Foo as Enum<typeof _Foo>;
            type Foo2 = ValueOf<typeof Foo2>;
        
            doSomething(Foo2.two);
        
        without triggering a type error too.

        With built in enums that would trigger an error

            enum Bar {
              No = 'No',
              Yes = 'Yes',
            }
            function doSomethingBar(message: Bar): void {
            }
            // no type error
            doSomethingBar(Bar.No);
            // type error
            doSomethingBar('No');
        
            enum Bar2 {
              No = 'No',
              Yes = 'Yes',
            }
            // type error
            doSomethingBar(Bar2.No);
        • spankalee a year ago

          You can always cast your way around nominal typing, even with enums. So you can do:

              doSomethingBar('No' as Bar);
          
          But you can make my Enum<> utility tighter by including the object type in the brand:

              type Enum<T> = {
                [K in keyof T]: T[K] & {__brand: T};
              }
          
          Then, if you had another const object Baz with the same value as Foo, you would get an error here:

              doSomething(Baz.one);
          
          The only time when you wouldn't get an error there is if the whole Baz enum object was assignable to Foo.
          • davorak a year ago

            > You can always cast your way around nominal typing, even with enums. So you can do: > doSomethingBar('No' as Bar);

            I think you can avoid that by not export type `Bar`. I think Bar then acts as an abstract type.

            On the other hand with, the branded version, even if you do not avoid exporting the type, even with when branded the object type, you can still get one enum masquerading as another by using the same name. See below where the original Foo is in enums.ts:

                import { Foo as Foo2, doSomething } from './enums'
            
                // And now, this will work:
                doSomething(Foo2.two);
                // But this will error:
                doSomething('2');
                // this is also an error since the type is not exported
                doSomething('2' as Foo2);
            
            
                type Enum<T> = {
                  [K in keyof T]: T[K] & {__brand: T};
                }
            
                const _Foo = {
                  one: '1',
                  two: '2',
                  three: '3'
                } as const;
                export const Foo = _Foo as Enum<typeof _Foo>;
                type ValueOf<X> = X[keyof X];
                type Foo = ValueOf<typeof Foo>;
            
                // no type error
                doSomething(Foo.two);
            
            I thought enums was the only way to get truly unique types in typescript, but I would be happy to be wrong here.
  • hassleblad23 a year ago

    Its a shame because I like the enum way of declaration a lot more.

    `const Foo = { Bar: 'bar' } as const` - this just feels a bit weird.

    • spankalee a year ago

      That's a taste thing. Personally, I like my TypeScript as a superset of JS with types, so I dislike all the custom value-space syntax.

      `const Foo = { Bar: 'bar' }` is how I would write an enum-like object in JS, so that's how I want to write it in TypeScript, just with added types.

    • DidYaWipe a year ago

      Yes, why do we have "const" at the beginning AND end?

      • moogly a year ago

        The `as const` at the end will ensure the type of `Foo` is not widened to a `string`.

        • DidYaWipe a year ago

          I don't know JavaScript very well, so I'll take your word for it. Seems like a language flaw to me, though. How many times should you have to say something?

  • demurgos a year ago

    The solution that you propose is a great relatively-lightweight solution for enums compatible with `erasableSyntaxOnly`. I see also other comments discussing other solutions which are worth comparing.

    From my side, I wanted to keep nominal typing and support for lightweight type-level variant syntax (I often use enums as discriminated union tags). Here is what I landed on:

        const Foo: unique symbol = Symbol("Foo");
        const Bar: unique symbol = Symbol("Bar");
        
        const MyEnum = {
          Foo,
          Bar,
        } as const;
        
        declare namespace MyEnum {
          type Foo = typeof MyEnum.Foo;
          type Bar = typeof MyEnum.Bar;
        }
        
        type MyEnum = typeof MyEnum[keyof typeof MyEnum];
    
        export {MyEnum};
    
    I posted more details in the erasable syntax PR [0].

    > This uses `unique symbol` for nominal typing, which requires either a `static readonly` class property or a simple `const`. Using a class prevents you from using `MyEnum` as the union of all variant values, so constants must be used. I then combine it with a type namespace to provide type-level support for `MyEnum.Foo`.

    > Obviously, this approach is even more inconvenient at the implementation side, but I find it more convenient on the consumer side. The implementer side complexity is less relevant if using codegen. `Symbol` is also skipped in `JSON.stringify` for both keys and values, so if you rely on it then it won't work and you'd need a branded primitive type if you care about nominal typing. I use schema-guided serialization so it's not an issue for me, but it's worth mentioning.

    > The "record of symbols" approach addresses in the original post: you can annotate in the namespace, or the symbol values.

    [0]: https://github.com/microsoft/TypeScript/pull/61011#issuecomm...

  • fixprix a year ago

    TypeScript/ES6 is such a great language with a feature set far ahead of other languages in many ways. The lack of enums though is a sore spot. I really hope that proposal you mentioned can move forward.

    Also you can improve your implementation with Object.freeze(Foo) and { one: Symbol("1") }

zdragnar a year ago

Seeing posts like these, I often feel alone preferring enums to string unions.

There are certain situations where refactoring a string in a union will not work but refactoring an enum will. I don't want to type strings when, semantically, what I want is a discrete type. I don't even care that they become strings in JS, because I'm using them for the semantic and type benefits, not the benefits that come with the string prototype.

  • forty a year ago

    That precisely one of my problem with enum: almost all TS type is structural typing, why have this exception enums being nominal typing?

    • zdragnar a year ago

      Classes aren't interchangable, excepting using a child when a parent is called for.

      Likewise, enums represent a discrete and unique set. The fact that there is either a number or a string used under the hood is irrelevant.

      I imagine using numbers or strings was useful for interop with vanilla JS (where JS needs to call a TS function with an enum as an argument), so it makes sense to use it instead of Symbols, which is what I typically pretend enumd are.

    • Tade0 a year ago

      And to add to the confusion Template Types let you compare enums as if they were strings.

  • jjani a year ago

    A year back or so I sat down, read through all the pros and cons including many HN posts just like this one, and I came to the same conclusion. Default to string enums. If I really need to iterate over the keys (generally an antipattern anyway), possibly refactor it into a const object literal. Never use const enums, number enums, or implicit enums.

  • bubblyworld a year ago

    Interesting point about semantics. I wish there was a way to get the best of both - discrete type (correct semantics) but one that is auto inferred from literals in contexts where the type system expects it (ergonomics of use). Perhaps there are good reasons that doesn't work though, I haven't thought through it much =P

  • eyelidlessness a year ago

    You’re not alone! I’ve given up the preference on team projects for pragmatic reasons, but the semantics of (string) enums are still my personal preference.

bubblyworld a year ago

One thing I find useful about enums is that they can be used as both types and values, which is ergonomic for decorator-based libraries (like class-validator, nestjs, mikro-orm, etc). The best approach I've found in union land is using const assertions and typeof, which I don't love.

Agree with the author that in almost every other way unions are better though... they play much more nicely with the rest of the type system. I find it endlessly annoying that I have to refer to enum members directly instead of just using literals like you can with union types.

  • disintegratorOP a year ago

    > One thing I find useful about enums is that they can be used as both types and values

    Makes sense. You can emulate that behavior by having an object literal with const assertion AND a union type of the same name derived from the object literal.

    • bubblyworld a year ago

      Right, yeah - this is what I meant by const and typeof. It's definitely an option, but I'm nervous of relying on the semantics of const like that. But maybe I shouldn't be, it seems pretty idiomatic?

      (the typeof part is just so you don't repeat yourself, or did you have something else in mind?)

motorest a year ago

Can anyone explain why enums are somehow bad but literal unions are supposed to be good?

I'll be blunt: at the surface level, it looks like literal unions are something that only someone with an irrational axe to grind against enums would ever suggest as a preferable alternative just to not concede that enums are fine.

If the problem lies in the low-level implementation details of enums, I cannot see any reason why they shouldn't be implemented the same way as literal unions.

So can anyone offer any explanation on why enums should be considered bad but literal unions should be good?

  • moogly a year ago

    TypeScript enums require codegen, which won't work in a type erasure world. This is explained in the article.

wruza a year ago

Parameter properties also gone? I only recently found out about these, so useful for data-ish classes.

  • disintegratorOP a year ago

    In principle you’ll still be able to use all of the features that existed before this flag but you’ll need to compile the code if targeting Node.js. I do think that this new flag is going to draw people away and we’ll probably see a bunch of tsconfig presets and boilerplate projects setting it to true.

    If you’re using a bundler then your’re not to going benefit from it in the medium term. It’s possible this will unlock faster build times with them in the future.

DanielHB a year ago

General programming languages theory question, is one supposed to iterate over enum entries or is that considered an antipattern? I have found myself needing to do that a few times and it always felt a bit dirty.

  • panstromek a year ago

    I wouldn't say it's an antipattern. E.g. listing all possible values of some user input field is a pretty natural use case for that.

  • williamdclt a year ago

    No that’s fine and a reasonable thing to do . In fact, I’d say it is one of the main points of enums and one of my biggest gripes against Go is the lack of that capability

    • floydnoel a year ago

      Swift can't handle it either, I had to write an extension to add that ability. Felt weird to me.

      • DanielHB a year ago

        Yeah which is why I originally asked, some other languages don't really have a way to iterate over enums.

forty a year ago

This is my preferred home made way of doing "Enum" in TS theses days https://gist.github.com/forty/ac392b0413c711eb2d8c628b3e7698... - it includes syntax to migrate from TS enum.

The member documentation point is a good one, I'll look what can be done with my solution.

MBCook a year ago

What do people find works better as a string enum replacement?

  const Thing {
    one: “one”,
    two: “two”,
    three: “three”
  } as const
Or just

  type Thing = “one” | “two” | “three”
I’ve been thinking of getting rid of the simple string enums I have but it’s not clear to me why one is preferred over the other by people.
  • braebo a year ago

    If all you need is a union type then the latter is plenty.

    If you need the actual strings to iterate over or validate against, deriving the value from an const array is helpful:

      const THINGS = ['one', 'two', 'three'] as const
    
      type Thing = THINGS[number]
  • pcthrowaway a year ago

    If you want to be able to use the syntax

        Thing.one
    
    or

        Thing.two
    
    while having each refer to a discrete symbol, you should probably use Symbols

    so:

        const Thing = {
          one: Symbol(),
          two: Symbol()
        } as const;
    
    will prevent anything equality matching that isn't intentional
homebrewer a year ago

const enums are almost never mentioned by these articles for some reason. They give you the best of both worlds: they're fully erasable, and have good LSP support (do no need to search for strings and bump into false matches — or even worse, for numbers).

  • furstenheim a year ago

    Lack of LSP support looks really bad in the article proposed solution :/.

    But const enum seems to have several pitfalls. https://www.typescriptlang.org/docs/handbook/enums.html

  • krona a year ago

    > const enums are almost never mentioned by these articles for some reason.

    I think it's because a lot of tooling (excepting TSC) doesn't support cross-file const enums. But I agree - it's one of the reasons I started using TypeScript way back in 2013. I wouldn't be able to write comprehensible performance sensitive code without it.

o11c a year ago

The problem with "just use literal strings/numbers" is that that's exactly the opposite of type safe. With them it is impossible to specify an argument of type `myenum | number | string`, despite that being commonly desired in some form.

When targeting javascript, it seems to me that the obvious approach is to use symbols for enums. But symbols have a lot of WET.

(of course, typescript's safety is unfixably broken in numerous other ways, so why bother?)

DidYaWipe a year ago

"Probably my favorite argument in steelmanning enums"

Whatever that's supposed to mean.

  • crummy a year ago

    Steelmanning is the opposite of strawmanning.

    In other words, it's making the strongest version of an argument for the opposing side of the argument. The author doesn't like enums but is talking about their best attributes.

    • DidYaWipe a year ago

      Thanks for the reply. I read a lot, and I've never encountered this term before. Seems like "in defense of" would be every bit as good, and universally understood.

      • swatcoder a year ago

        It's an internet term of recent origin, from a specific community, not a traditional one.

        It's good to be familiar with the word, as it comes up in adjacent communities like this one, but like with most slang, there are indeed clearer ways to say the same thing.

        But also, some people don't realize that they've picked up a slang term or that people outside their community are part of discussions like we have here, so it comes up a lot. Now that you've spotted it, you'll likely see it here a lot.

        (FWIW, I hate it and am grateful that nobody can see me roll my eyes when its used. Same for "motte and bailey" and other comically pseudo-erudite slang from those folks)

        • DidYaWipe a year ago

          Thanks. Glad to hear from someone else who despises this kind of douchily obscure jargon. Its smug adherents love to "flag" anyone who calls it out here, or calls out similarly douchey posts whose titles lack any description.

lelandfe a year ago

> TypeScript 5.8 is out bringing with it the --erasableSyntaxOnly flag

TypeScript sure loves the "our only documentation lives in the changelog" approach to stuff, huh?

- The on-site Algolia search returns 0 results for "erasableSyntaxOnly"

- The blocked-from-search release notes[0] looks like actual documentation - but urges to check out the PR[1] "for more information," despite the PR description being essentially blank.

- The CLI options page[2] describes it thus: "Do not allow runtime constructs that are not part of ECMAScript," with no links to learn more about what that means.

Edit: actually, I take it back! Clicking the flag on the CLI page takes one to an intimidating junk drawer page... but my issues with discoverability stand: https://www.typescriptlang.org/tsconfig/#erasableSyntaxOnly

[0] https://www.typescriptlang.org/docs/handbook/release-notes/t...

[1] https://github.com/microsoft/TypeScript/pull/61011

[2] No idea why on-site search doesn't pick this up: https://www.typescriptlang.org/docs/handbook/compiler-option...

Keyboard Shortcuts

j
Next item
k
Previous item
o / Enter
Open selected item
?
Show this help
Esc
Close modal / clear selection