An ode to TypeScript enums(blog.disintegrator.dev) |
An ode to TypeScript enums(blog.disintegrator.dev) |
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.Sum types (without payloads on the instances they are effectively enums) should not require a evening filling ceremonial dance event to define.
(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.
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.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.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`?
Gleam has real sum types and can compile to JS.
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.
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');`const Foo = { Bar: 'bar' } as const` - this just feels a bit weird.
`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.
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...
Also you can improve your implementation with Object.freeze(Foo) and { one: Symbol("1") }
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.
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.
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.
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.
(the typeof part is just so you don't repeat yourself, or did you have something else in mind?)
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?
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.
The member documentation point is a good one, I'll look what can be done with my solution.
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.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] Thing.one
or Thing.two
while having each refer to a discrete symbol, you should probably use Symbolsso:
const Thing = {
one: Symbol(),
two: Symbol()
} as const;
will prevent anything equality matching that isn't intentionalBut const enum seems to have several pitfalls. https://www.typescriptlang.org/docs/handbook/enums.html
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.
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?)
Whatever that's supposed to mean.
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.
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...
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)
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); 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.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.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.
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...
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).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.