It seems that torwards the end of 2022 the collective hivemind of the TypeScript programming world decided that enums are terrible. While I mostly agree with the premise that TypeScript enums are not good the solutions that are presented often only deal with String enums. For my work on SDL_ts I really need a solution for Numeric enums as SDL is full of them.
Uses for numeric enums really boil down to two use cases: the value must be either exactly
one of the specified values or some bitwise combination of them. We'll create the type helpers
Enum
and Flags
to implement the two use cases.
Enum
is when the value must be one of the specified values and only one of the specified values and is easy enough to implement:
export type Enum<T> = T[keyof T];
export const MyEnum = {
// ^? const MyEnum: { readonly One: 1; readonly Two: 2; }
One: 1,
Two: 2
} as const;
export type MyEnum = Enum<typeof MyEnum>;
// ^? type MyEnum = 1 | 2
// This works.
const foo: MyEnum = MyEnum.Two;
// @ts-expect-error This does not.
const bar: MyEnum = 3;
The type helper Enum
just creates a union of all the values of the object. T
could
also be more restrictive but it doesn't have to be for our purposes.
To use the helper we create the export const MyEnum
first to hold the values
and mark it as const
. This changes the type of MyEnum
from
const MyEnum: { One: number; Two: number; }
to
const MyEnum: { readonly One: 1; readonly Two: 2; }
. Then comes the export type MyEnum
which passes typeof MyEnum
to our Enum
helper. The typeof MyEnum
tells TypeScript that
we're talking about the const
that we defined beforehand. The resulting type is a union
of 1 | 2
.
Flags
is a bit more complicated:
declare const _: unique symbol;
export type Flags<T, Name> =
| {
[K in keyof T]: { [_]: Name } & T[K];
}[keyof T]
| number;
export const MyFlags = {
// ^? const MyFlags: { readonly One: 1; readonly Two: 2; }
One: 1,
Two: 2
} as const;
export type MyFlags = Flags<typeof MyFlags, "MyFlags">;
// ^? type MyFlags = number | ({ [_]: "MyFlags"; } & 1) | ({ [_]: "MyFlags"; } & 2)
// This works.
const foo: MyFlags = MyFlags.Two;
// This also works.
const bar: MyFlags = MyFlags.One | MyFlags.Two;
// Unfortunately this also works though.
const baz: MyFlags = 4;
The Flags
type helper looks quite gnarly but
let's go through it step by step. First declare const _: unique symbol;
just creates a
Symbol
that we can use at the type level. Then first part of the union
({ [K in keyof T]: { [_]: Name } & T[K]; }[keyof T]
) in the type helper can be thought of
like calling map on the type and performing what is known as
branding or nominal typing
on each value of the enum using the string provided in the 2nd parameter of the type helper.
The last part of the helper just unions the newly created type together with number
.
Using Flags
is almost the same as using Enum
but we'll have to provide a string for the
branding which I just set to be the same as the name of the enum.
Why do the enum values have to be branded? Without the branding TypeScript
widens the resulting type to just be number
. The examples still work as before
but the Intellisense is completely lost. The IDE will just show the type number
with
no mention of the name of the enum. See this playground link