TypeScript Enums

Published on 2023-01-22
#programming #typescript

For one of my current projects, I’ve been spending a lot of time thinking about enums in TypeScript. More specifically, I’ve been thinking about how to represent values with keys. This is a pretty basic thing to do in programming, and in most cases, you wouldn’t need to think about this at all. There are hash maps for this stuff. JavaScript has objects.

The problem arises from code gen though, which is arguably one of the most important parts of building for the web. Bundle size is a critical factor in my work; this is doubly true when I’m working on a project that has an inherently large bundle size (games), simply because they are complex. Those WebGL shaders have to live somewhere, after all.

Considering the Problem

This project utilizes a client-server architecture. The client is very thin, as I can (at least currently) get away with no client-side prediction logic. The server is completely authoritative.

So, I am communicating via WebSockets to a server written in Rust. As performance is a concern (round-trip latency is critical without client-side prediction), I use array buffers to pack the data. Messages will always start with a PacketID; basically a "header". This is required in order to decode them, both client- and server-side. Each PacketID is just an unsigned 8-bit integer.

Every action a player can perform needs to have an associated PacketID (for example, PlayerMove, PlayerJoin, PlayerLeave). A sizable game will have many of these IDs, and often there will be "subtypes" as well.

I also want to be able to automatically generate TypeScript representations from my server code. This is important, as having to sync PacketIDs between multiple files is an easy source of bugs. It also takes a lot of time.

From my research, there are really 2 ideal solutions to this problem, with one additional thrown in for fun: enums, const objects, and unions.

Enums

These get a lot of hate in the TypeScript community; generally I understand why, but it's also difficult to have reasonable discussions when folks take such dogmatic stances (or simply parrot what they've heard).

enum PacketId {
    PlayerJoin = 0,
    PlayerLeave = 1,
}

const ws = new WebSocket("ws://localhost:8080")

const send = (id: PacketId) => {
    const buf = new ArrayBuffer(1)
    const view = new DataView(buf)

    switch (id) {
        case PacketId.PlayerJoin: {
            view.setUint8(0, id)
            break
        }
        case PacketId.PlayerLeave: {
            view.setUint8(0, id)
            break
        }
    }

    ws.send(buf)
}

send(PacketId.PlayerJoin)

To get the easy pickings out of the way: with a modern bundler, enums do not generate additional code. I use esbuild via Vite, which automatically inlines all enums with no additional configuration. It makes no distinction between const enum and enum, and as far as I understand, handles cross-module inlining (which is a danger of using TS const enums).

Now, there is still a massive downside here: type safety is lost. I can put any number in the send function and TypeScript won’t complain. One might argue: what is the point of using TS if you’re giving up type safety?

To a point, I do agree - but frankly, TS is not a panacea. I think it’s merely okay. I do use it for all my projects, but I always wish it were more like Rust (or any language with a powerful type system). Of course, I don't think it's "good practice" to drop type safety willy-nilly, but when the alternatives generate objectively worse code (by one of the metrics you're measuring), you have to make a decision. As I'm optimizing for bundle size here, I am willing to take the loss of type safety on this function.

My send function is only called locally, within one module. If a plain integer were passed in, it would stick out. It would also not make much sense to pass in a plain integer, as manually encoding PacketIDs would be a nightmare. You would want to use the enum mapping that exists.

The generated code is simple. Everything was inlined as I would like.

// Generated code (minified with whitespace)
const c = new WebSocket("ws://localhost:8080"),
    l = (o) => {
        const r = new ArrayBuffer(1),
            n = new DataView(r)
        switch (o) {
            case 0: {
                n.setUint8(0, o)
                break
            }
            case 1: {
                n.setUint8(0, o)
                break
            }
        }
        c.send(r)
    }
l(0)

Const Objects

This solution is fantastic, and it would absolutely be my preferred choice if it weren’t for the generated code. I get type safety and friendly key-value mappings. It's almost perfect, but the generated code grows linearly with the number of properties on our object, and this is a major concern.

const PacketId = {
    PlayerJoin: 0,
    PlayerLeave: 1,
} as const
type PacketId = typeof PacketId[keyof typeof PacketId]

const ws = new WebSocket("ws://localhost:8080")

const send = (id: PacketId) => {
    const buf = new ArrayBuffer(1)
    const view = new DataView(buf)

    switch (id) {
        case PacketId.PlayerJoin: {
            view.setUint8(0, id)
            break
        }
        case PacketId.PlayerLeave: {
            view.setUint8(0, id)
            break
        }
    }

    ws.send(buf)
}

send(PacketId.PlayerJoin)

In this specific scenario I only have 2 properties on our object, so it is not a big deal - but what happens when there are 50? What about the subtype with another 20 properties? It adds up quickly, thus making it not worth it to me.

That said, I am quite curious why this cannot be inlined by the bundler. I could not find any open discussions on the esbuild repository regarding const assertions and inlining. Perhaps I am missing an obvious reason as to what would make this dangerous and/or untenable to inline.

// Generated code (minified with whitespace)
const i = { PlayerJoin: 0, PlayerLeave: 1 }, // <--- This is the problem
    l = new WebSocket("ws://localhost:8080"),
    a = (o) => {
        const r = new ArrayBuffer(1),
            n = new DataView(r)
        switch (o) {
            case i.PlayerJoin: { // <--- Why can't this be inlined?
                n.setUint8(0, o)
                break
            }
            case i.PlayerLeave: { // <--- ...and this?
                n.setUint8(0, o)
                break
            }
        }
        l.send(r)
    }
a(i.PlayerJoin) // <--- ...and this?

String Unions

This is an option I never considered for the problem at hand, but I wanted to include it for completeness. I think that most problems would prefer string unions over enums, which is why it is the de facto recommended solution to code involving enums in TypeScript.

type PacketId = "PlayerJoin" | "PlayerLeave" | "PlayerMove" | "GameState"

const ws = new WebSocket("ws://localhost:8080")

const send = (id: PacketId) => {
    const buf = new ArrayBuffer(1)
    const view = new DataView(buf)

    switch (id) {
        case "PlayerJoin": {
            view.setUint8(0, 0)
            break
        }
        case "PlayerLeave": {
            view.setUint8(0, 1)
            break
        }
        case "PlayerMove": {
            view.setUint8(0, 2)
            break
        }
        case "GameState": {
            view.setUint8(0, 3)
            break
        }
    }

    ws.send(buf)
}

send("PlayerJoin")

There are various problems though. First, the generated code is heavy. This should not be a surprise, as it is using strings instead of (small) numbers. It is impossible to see savings here unless each string was represented by a single ASCII character - which tosses out the entire point of using a key-value mapping in the first place.

More importantly, though, I have to manually supply each PacketID. This automatically makes the solution a non-starter for this problem.

The ideal situation is that I do not need to think about the PacketIDs at all. I just use the existing mapping, and it works. Outside of debugging, one wouldn't even be looking at the byte array headers. In most cases you'd know immediately if the header was borked as your packet would be siphoned down a completely different branch.

// Generated code (minified with whitespace)
const c = new WebSocket("ws://localhost:8080"),
    l = (s) => {
        const r = new ArrayBuffer(1),
            o = new DataView(r)
        switch (s) {
            case "PlayerJoin": { // <--- Bloat
                o.setUint8(0, 0) // <--- Massive source of bugs
                break
            }
            case "PlayerLeave": { // <--- Bloat
                o.setUint8(0, 1) // ...also annoying to write
                break
            }
            case "PlayerMove": { // <--- Bloat
                o.setUint8(0, 2) // ...
                break
            }
            case "GameState": { // <--- Bloat
                o.setUint8(0, 3) // ...
                break
            }
        }
        c.send(r)
    }
l("PlayerJoin") // <--- Bloat

As it stands, I will keep using enums for this use-case, though I'll also continue to explore alternatives. Perhaps I'm overlooking another obvious solution that will hit me as soon as I am finished writing this!

Back Home