Hacker News new | ask | show | jobs
by spankalee 478 days 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.
5 comments

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.

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

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

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

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.
That single reason is all that matters, because it maps directly to what the platform actually understands, instead of adding another layer to debug.
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`?

> (any I forgot?)

Gleam has real sum types and can compile to JS.

https://gleam.run/

Indeed! I thought it was BEAM only...
> 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.
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');
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);
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.
> 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.
You can get nominal types with classes by adding a private field, but you can't put private or protected members in a type or interface. `typeof class` might help, but I think they don't want to allow nominal interfaces: https://github.com/microsoft/TypeScript/issues/41824
What if you changed __brand to be a Symbol that was never exported?
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.

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.

Yes, why do we have "const" at the beginning AND end?
The `as const` at the end will ensure the type of `Foo` is not widened to a `string`.
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.

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

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