Making TypeScript enums not suck in 500 bytes or less.

oofdere

Tibet Tornaci

Posted on December 6, 2023

Making TypeScript enums not suck in 500 bytes or less.

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 ]
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

And update our type:

type Color = {
    color: ColorEnum,
    value: ???
}
Enter fullscreen mode Exit fullscreen mode

Wait, what should our type be?

  • number[]? I guess it would work if I change all the code that's written to handle numbers.
  • 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'
Enter fullscreen mode Exit fullscreen mode

Let's start by initializing the enum again:

type Colors = {
    Red: number;
    Blue: number;
    Green: number;
};
Enter fullscreen mode Exit fullscreen mode

Some things to take note of:

  1. As you can see, this is just a standard type.
  2. The keys of the enum are the types they store.
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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 ]
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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>'.
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Valid values obviously work:

const rgb = pack<Colors>("Rgb", [128, 128, 128]) //=> const rgb: Enum<Colors>
Enter fullscreen mode Exit fullscreen mode

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]
    });
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

(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 = {}));
Enter fullscreen mode Exit fullscreen mode

And this is how it compiles a crabrave enum:

// this space intentionally left empty
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
oofdere
Tibet Tornaci

Posted on December 6, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

tsParticles 2.9.3 Released
javascript tsParticles 2.9.3 Released

February 12, 2023

tsParticles 2.9.2 Released
javascript tsParticles 2.9.2 Released

February 12, 2023

tsParticles 2.9.0 Released
javascript tsParticles 2.9.0 Released

February 10, 2023

tsParticles 2.8.0 Released
javascript tsParticles 2.8.0 Released

January 19, 2023