Making TypeScript enums not suck in 500 bytes or less.
Tibet Tornaci
Posted on December 6, 2023
Over the past two weeks, I've been working on crabrave, a <500B (when bundled) library that brings Rust-style Enums, Options, and Results into TypeScript.
This is an introduction to what I call packed enums, which are essentially the same as Rust enums, in that they can hold arbitrary information alongside the thing they represent. But first,
A lot of people have talked at length about the technical problems of TypeScript enums; their runtime cost, inconsistent behaviour, etc. etc.
These are all very bad problems, and this implementation fixes those as well (at least the ones I know of), but I think the bigger issue is that TypeScript enums just aren't very useful.
Let me demonstrate:
The problem with vanilla TS enums
The following is a standard TypeScript enum:
enum ColorEnum {
Red,
Green,
Blue
}
We can use it like this:
const red = ColorEnum.Red
const green = ColorEnum.Green
const blue = ColorEnum.Blue
red === ColorEnum.Red // true
red === ColorEnum.Green // false
Pretty simple, right? But what if we wanted to store the intensity of that color as well? Well, we could maybe make a type:
type Color = {
color: ColorEnum,
value: number
}
const red: Color = {
color: ColorEnum.Red,
value: 128
}
Let's also make a function that takes a Color
and makes it into an [R, G, B]
tuple.
function toRGB(color: Color) {
switch (color.color) {
case ColorEnum.Red: return [color.value, 0, 0]
case ColorEnum.Green: return [0, color.value, 0]
case ColorEnum.Blue: return [0, 0, color.value]
}
}
console.log(toRGB(red)) // [ 128, 0, 0 ]
And that looks fine at first, but what if our client comes to us and kindly informs us of the following:
MAKE IT SO I CAN USE RGB VALUES OR YOU'RE FIRED!
Well, ok, can't be too hard. Let's just add Rgb to our enum...
enum ColorEnum = {
Red,
Green,
Blue,
Rgb
}
And update our type:
type Color = {
color: ColorEnum,
value: ???
}
Wait, what should our type be?
-
number[]
? I guess it would work if I change all the code that's written to handlenumber
s. -
number | [number, number, number]
? A bit nicer, but what if someone tries to set a single number for an RGB value? -
class Color
with conversion functions? Bloat much? -
any
? Yeah, no.
And even if you do all the changes to handle RGB today, what are you going to do tomorrow when they ask you for hexadecimal and HSL and HSV and alpha channels and who knows what else?
Packed Enums, that's how! Let's work through the same example with one.
How Packed Enums fix everything
Import the library like this:
import { type Enum, match, pack } from '@oofdere/crabrave'
Let's start by initializing the enum again:
type Colors = {
Red: number;
Blue: number;
Green: number;
};
Some things to take note of:
- As you can see, this is just a standard type.
- The keys of the enum are the types they store.
- It's very similar to Rust's enums.
We can use it like this:
const red = pack<Colors>("Red", 128) //=> const red: Enum<Colors>
const green = pack<Colors>("Blue", 128) //=> const green: Enum<Colors>
const blue = pack<Colors>("Green", 128) //=> const blue: Enum<Colors>
pack<Enum>(k, v)
takes in our enum definition (the Colors
type we created earlier) and packs it into an object based on the key
and value
of that specific enum entry. This means you get autocomplete in your IDE for both key
and value
.
Not as clean as the default TS enum syntax, but note that we passed the intensity into pack()
, which means we don't have to define type Color
like we did before.
Now let's make that toRGB function again:
function toRGB(color: Enum<Colors>) { // returns number[]
return match(color, {
Red: (x) => [x, 0, 0], //=> Red: (x: number) => number[]
Green: (x) => [0, x, 0], //=> Green: (x: number) => number[]
Blue: (x) => [0, 0, x], //=> Blue: (x: number) => number[]
});
}
console.log(toRGB(blue)); // [ 0, 0, 128 ]
Great! We have all the functionality of the original again! Now let's start implementing RGB colors by updating our Color enum:
type Colors = {
Red: number;
Blue: number;
Green: number;
Rgb: [number, number, number];
};
TypeScript will start screaming at us as soon as we do this:
colors.ts:16:22 - error TS2345: Argument of type '{ Red: (x: number) => number[]; Green: (x: number) => number[]; Blue: (x: number) => number[]; }' is not assignable to parameter of type 'Functionify<Colors>'.
Property 'Rgb' is missing in type '{ Red: (x: number) => number[]; Green: (x: number) => number[]; Blue: (x: number) => number[]; }' but required in type 'Functionify<Colors>'.
This might look intimidating at first, but if we look at the start of the indented line, we'll see that TypeScript is actually warning us that our match()
call doesn't handle the Rgb
case we just added to our enum.
The same thing will happen if we try to pack an invalid value into Rgb
:
const rgb = pack<Colors>("Rgb", 128) //=> const rgb: Enum<Colors>
The TypeScript compiler will tell us what we did wrong in three different ways:
Argument of type '["Rgb", 128]' is not assignable to parameter of type 'EnumUnion<Colors>'.
Type '["Rgb", 128]' is not assignable to type '["Rgb", [number, number, number]]'.
Type at position 1 in source is not compatible with type at position 1 in target.
Type 'number' is not assignable to type '[number, number, number]'.ts(2345)
Valid values obviously work:
const rgb = pack<Colors>("Rgb", [128, 128, 128]) //=> const rgb: Enum<Colors>
Let's update our toRGB()
function now:
function toRGB(color: Enum<Colors>) {
return match(color, {
Red: (x) => [x, 0, 0], //=> Red: (x: number) => number[]
Green: (x) => [0, x, 0], //=> Green: (x: number) => number[]
Blue: (x) => [0, 0, x], //=> Blue: (x: number) => number[]
+ Rgb: (x) => x //=> Rgb: (x: [number, number, number]) => [number, number, number]
});
}
This works, but the return type is now number[] | [number, number, number]
, which isn't ideal. We can work around this with the as
keyword:
function toRGB(color: Enum<Colors>) {
return match(color, {
// ...
Rgb: (x) => x //=> Rgb: (x: [number, number, number]) => [number, number, number]
}) as [number, number, number];
}
Another option is to return Enum<Colors>
, which is useful if you want to keep using the functions built to handle the enum:
function toRGB(color: Enum<Colors>) { // returns Enum<Colors>
return match(color, {
Red: (x) => pack<Colors>("Rgb", [x, 0, 0]), //=> Red: (x: number) => Enum<Colors>
Green: (x) => pack<Colors>("Rgb", [0, x, 0]), //=> Green: (x: number) => Enum<Colors>
Blue: (x) => pack<Colors>("Rgb", [0, 0, x]), //=> Blue: (x: number) => Enum<Colors>
Rgb: (x) => pack<Colors>("Rgb", x) //=> Rgb: (x: number) => Enum<Colors>
});
}
console.log(toRGB(blue).v);
Both are fine, do whichever makes more sense for your project.
And of course, fearlessly add new features, knowing the compiler will tell you exactly where your code needs to be updated to handle them:
type Colors = {
Red: number;
Blue: number;
Green: number;
Rgb: [number, number, number];
Rgba: [number, number, number, number];
Hsl: {
hue: number,
saturation: number,
lightness: number
},
Css: string,
None: null
};
colors.ts:26:22 - error TS2345: Argument of type '{ Red: (x: number) => Enum<Colors>; Green: (x: number) => Enum<Colors>; Blue: (x: number) => Enum<Colors>; Rgb: (x: [number, number, number]) => Enum<...>; }' is not assignable to parameter of type 'Functionify<Colors>'.
Type '{ Red: (x: number) => Enum<Colors>; Green: (x: number) => Enum<Colors>; Blue: (x: number) => Enum<Colors>; Rgb: (x: [number, number, number]) => Enum<...>; }' is missing the following properties from type 'Functionify<Colors>': Rgba, Hsl, Css, None
(Almost) Zero-cost
This is how TypeScript compiles the vanilla enum we built earlier:
"use strict";
var ColorEnum;
(function (ColorEnum) {
ColorEnum[ColorEnum["Red"] = 0] = "Red";
ColorEnum[ColorEnum["Green"] = 1] = "Green";
ColorEnum[ColorEnum["Blue"] = 2] = "Blue";
})(ColorEnum || (ColorEnum = {}));
And this is how it compiles a crabrave enum:
// this space intentionally left empty
And in fact, the entirety of the enum logic bundles down to just this:
// src/enum.ts
var pack = (...entry) => entry;
var match = (pattern, arms) => arms[pattern[0]](pattern[1]);
var matchPartial = (pattern, arms, fallback) => (arms[pattern[0]] || fallback)(pattern[1]);
export {
pack,
matchPartial,
match
};
Or, after minification: var o=(...i)=>i,w=(i,u)=>u[i[0]](i[1]),x=(i,u,d)=>(u[i[0]]||d)(i[1]);export{o as pack,x as matchPartial,w as match};
(116 bytes)
This is a cost you pay once across your whole entire project, and then you can make and use as many enums as you'd like without any additional penalty.
The magic is in the type system, and that's what vanilla TS enums miss.
Next time, I'll be posting a guide on Result<T, E>
, a gentler way of handling errors.
In the meantime, check out the docs (very much under construction) and the code
Feedback and contributions welcomed!
Posted on December 6, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.