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.
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.
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.
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.
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.
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.
> 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.
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.
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.
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);
> 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.
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?
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.
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") }
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.
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.
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.
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
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.
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.
> 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.
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?)
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?
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.
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.
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
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).
> 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.
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?)
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.
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.
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)
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.
> 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.
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.
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.