DEV Community

Cover image for "I Was Bored, So I Brought Rust Enums to TypeScript" - A Tale of Questionable Life Choices
Ja
Ja

Posted on • Edited on

"I Was Bored, So I Brought Rust Enums to TypeScript" - A Tale of Questionable Life Choices

Hey there, fellow code enthusiasts! Gather 'round as I regale you with the tale of how I, in a fit of what can only be described as "productive procrastination," decided to implement Rust-style enums in TypeScript. Why, you ask? Well, why not? It's not like I had anything better to do, like, I don't know, learning to knit or finally organizing my sock drawer.

The Genesis of a Questionable Idea

Picture this: It's 2 AM on a Tuesday night (or Wednesday morning, if you're one of those "glass half full" types). I'm sitting in my pajamas, surrounded by empty coffee mugs and a concerning number of rubber ducks. I've just finished binge-watching a series about rust... you know, the oxidation process of metal. Fascinating stuff, really.

Suddenly, my caffeine-addled brain makes a connection: Rust... Rust programming language... TypeScript... EUREKA! Why not bring Rust's delightful enum system to TypeScript? It's the kind of idea that only sounds good when you're running on fumes and sheer coding hubris (...or admittedly, when you're building an app with tauri like I happen to be 😬😅).

But Why TF Not!?

The question isn't "Why?" but "Why not?" After all, what's the worst that could happen? (Narrator: A lot, actually, but let's not spoil the fun.)

  1. Because I can: Sometimes, the best reason to do something is simply because you can. It's the same logic that leads people to climb mountains or eat ghost peppers or code in ruby.

  2. For the glory: Imagine the bragging rights! "Oh, you implemented a to-do app? That's cute. I brought an entire enum system from one language to another. No big deal."

  3. Learning opportunity: Or at least that's what I told myself to justify the impending sleep deprivation.

  4. Because I'm unemployed: And what better way to show you're an imposter than to implement a strongly typed data structure into a fakely typed toy language that compiles down to Joseph Smith?

After reflecting on these points, there's no possible way I could turn back now.

The Journey Begins

Image description
Armed with nothing but determination, a vague understanding of Rust, and enough caffeine to power a small city, I set out on my quest. Here's a snippet of what I came up with:

const TAG = Symbol("__tag__");
const UNION = Symbol("__union__");

export type Variant<T extends string, V = {}, U = {}> = {
    [TAG]: T;
    [UNION]: U;
} & V;

// ... (more type definitions that made sense at 3 AM)

export const choice = <T extends Record<string, any>>(def: T) => {
    // Implementation details that seemed like a good idea at the time
};
Enter fullscreen mode Exit fullscreen mode

I know... I've barely shown you anything... But as I typed this out, I couldn't help but feel like Dr. Frankenstein, stitching together parts of different languages to create something that may or may not want to eat my brains (yes I have 2 🧠s... and they don't quite like each other).

And honestly, I intended in this article to go section by section through this implementation to detail each individual piece... but lets be honest, no one's gonna read this, and even fewer are gonna use this implementation (nor should they probably). So, instead I'm just gonna dump the whole thing below, then go over some things we've learned.

What I Wanted V. What I Got

So, this is what I got...

const TAG = Symbol("__tag__");
const UNION = Symbol("__union__");
// Struct type to automatically include the 'type' property
export type Variant<T extends string, V = {}, U = {}> = {
    [TAG]: T;
    [UNION]: U;
} & V;
export namespace Variant {
    export type Tag<T extends Variant<any>> = `${T[typeof TAG]}`;
}
export type Variants<T> = {
    [K in keyof T]: Variant<K & string, T[K], Union<T>>;
};
export namespace Variants {
    export type Of<T extends Union<any>> = { [K in keyof T]: ReturnType<T[K]> };
    export type From<T extends Variant<any>> = Variants.Of<T[typeof UNION]> & {
        [Key in Variant.Tag<T>]: T;
    };
}
export type Union<T> = {
    [K in keyof T]: <U extends T[K]>(
        value: U
    ) => Variant<K & string, U, Union<T>>;
};
export namespace Union {
    export type Of<T extends Variant<any> | Variants<any>> = T extends {
        [UNION]: infer U;
    }
        ? U
        : T extends Variants<any>
        ? T[keyof T][typeof UNION]
        : never;
}
export type Choice<T> = Variants<Readonly<T>>[keyof T];
export namespace Choice {
    export type Of<T extends Variants.Of<any> | Union<any>> =
        T extends Union<any> ? Variants.Of<T>[keyof T] : T[keyof T];
}

type CompleteMatchPattern<T, R> = {
    [K in keyof T]: (value: T[K]) => R;
};
type PartialMatchPattern<T, R> = {
    [K in keyof T]?: (value: T[K]) => R;
} & { _: () => R };
export type MatchPattern<T, R> =
    | CompleteMatchPattern<T, R>
    | PartialMatchPattern<T, R>;

export const choice = <T extends Record<string, any>>(def: T) => {
    let Union: any = {};
    let struct: any = {};
    for (let [key, defaultValue] of Object.entries(def)) {
        struct[key] = (value: object) =>
            Object.assign(Object.create(Union), {
                ...defaultValue,
                ...value,
                [TAG]: key,
                [UNION]: struct
            });
    }
    return struct as Union<T>;
};

export function match<
    T extends Variant<any>,
    P extends MatchPattern<Variants.From<T>, R>,
    R
>(
    value: T,
    patterns: P
): P extends MatchPattern<Variants.From<T>, infer R> ? R : R {
    const pattern = patterns[value[TAG] as keyof T[typeof UNION]];
    if (!pattern) {
        throw new Error(`Unhandled variant: ${value[TAG]}`);
    }
    return pattern(value as any) as P extends MatchPattern<
        Variants.From<T>,
        infer R
    >
        ? R
        : R;
}

Enter fullscreen mode Exit fullscreen mode

And as much as I tried to match the Rust syntax as close as possible, obviously thats impossible because typescript aint rust. So as beautiful as this is:

//example.rs
enum Sandwich {
  Handburger{ with: Vec<Ingredient> },
  Hero { with: Vec<Ingredient>, size: int8 },
  Hotdog { with: Vec<Condiment> }
}
Enter fullscreen mode Exit fullscreen mode

Typescript, on the other hand, doesn't believe a hotdog is a sandwich and moreover has already commandeered the enum keyword and implemented them like someone who's never had to make a real choice. So thats what I called my enums... a "choice".

And because types in TYPEscript are imaginary, my choice type also required a bit of a special syntax when defining one:

//example.ts
type Sandwich = Choice.Of<typeof Sandwich>
const Sandwich = choice({
  Handburger: { with: list(Ingredient) },
  Hero: { with: list(Ingredient), size: number },
  Hotdog: { with: list(Ingredient) }
})
Enter fullscreen mode Exit fullscreen mode

Yeah... you're eyes aren't deceiving you. In order to get the ergonomics I've become used to from rust's enums, I had to implement a hack to shadow the choice constant with its type definition. This is only done so you can pattern match on type Sandwich and typescript can interpret how to validate its branches. (more on matching later I guess... but i'm getting tired so probably wont be long now before I wrap this thing up).

The "Aha!" Moment (or Was It Just Delirium?)

After what felt like years but was probably just a few hours of coding and muttering to myself, I had a working implementation. To test it, I decided to model something close to my heart: a system for categorizing my growing collection of coding-related excuses.

import { Choice, choice, match } from "./enum";
import { number, string } from "./types";

type Excuse = Choice.Of<typeof Excuse>;
let Excuse = choice({
    CoffeeShortage: { cupsNeeded: number },
    ComputerProblem: { errorMessage: string },
    Inspiration: { awaitedMuse: string },
});

function explainDelay(excuse: Excuse): string {
    return match(excuse, {
        CoffeeShortage: ({ cupsNeeded }) => 
            `I need ${cupsNeeded} more cups of coffee before I can function.`,
        ComputerProblem: ({ errorMessage }) => 
            `My computer says "${errorMessage}". I'm as confused as you are.`,
        Inspiration: ({ awaitedMuse }) => 
            `I'm waiting for ${awaitedMuse} to inspire me. Any minute now...`,
    });
}

const myExcuse = Excuse.Inspiration({ awaitedMuse: "the coding gods" });
console.log(explainDelay(myExcuse));
// Output: I'm waiting for the coding gods to inspire me. Any minute now...
Enter fullscreen mode Exit fullscreen mode

I had to shadow the primitive types in typescript to make it looks as natural as possible. And as I ran this code and saw it work, I experienced a mixture of elation and horror. I had done it, but at what cost?

The Cost (Besides Questioning My Life Choices)

  1. Time is money: And since this whole thing uses runtime validations, you gotta pay to play with this. Most of that cost is at construction, though.

  2. Space is a waste: so who cares how much this might cost you. I thought space is supposed to be infinite anyways...

  3. Frailty, thy name is Typescript: This is a hack... lets be honest. But fun to explore.

The Aftermath

So, what did I gain from this adventure, besides dark circles under my eyes and an intimate knowledge of every error message TypeScript can throw?

  1. A deeper understanding of type systems: And a newfound respect for language designers who do this for a living.

  2. Improved problem-solving skills: If you can implement cross-language features, you can probably figure out world peace, am I right? Maybe?

  3. A great story for parties: Because nothing says "life of the party" like talking about enum implementations, right?

Conclusion: Was It Worth It?

As I sit here, sipping my nth cup of coffee and staring at my creation, I ponder: Was it worth it? Was this a valuable use of my time?

Absolutely not. And I'd do it again in a heartbeat.

Because sometimes, dear reader, the journey is more important than the destination. And sometimes, you just need to do something ridiculously complex for no good reason other than to prove you can.

So the next time you find yourself bored and considering doing something productive, remember my tale. You could learn a new skill, contribute to open source, or maybe, just maybe, you could embark on your own questionable coding adventure.

After all, why not?

P.S: This whole thing actually has me thinking about a Result type that is a bit more strict and could actually be useful in typescript. But I'll maybe do that one another restless night if anyone gets interested.

P.P.S: I know I'm probably leaving out parts of my implementation in this Article... and for that, I'm sorry but its now 7am after an all nighter, and I just cant do everything... but feel free to comment with any questions you may have.

I posted the code samples on codesandbox for anyone to play with. Feel free to fork it.

Top comments (11)

Collapse
 
iannismccluskey profile image
iannismccluskey

You made me laugh out loud in the Paris metro. Thanks a lot. Very inspiring.

Collapse
 
jasuperior profile image
Ja

😜😂 Then I have done my job 😉 Glad I could laugh... mission accomplished

Collapse
 
jonesbeach profile image
Jones Beach

Hahaha this was very fun. And impressive! As a fellow language fan, I'm looking forward to your TypeScript Result type!

Collapse
 
jasuperior profile image
Ja

😜😊❤️ Thanks! and Stay tuned... It will be coming to a theatre near you!

Collapse
 
webbureaucrat profile image
webbureaucrat

Neat!

Have you ever looked into ReScript? It's like the midpoint between Rust and TypeScript. If you're into this sort of thing you might like it.

Collapse
 
jasuperior profile image
Ja • Edited

Ah! yes! Rescript! the OCaml clone that compiles to JS. I've seen it, and I'm a fan of functional languages like those in general. But in the case of Rescript, some of the syntax choices kinda bug me 😅 ( I hate the way they do named arguments for instance). Still tho, I only started doing this to cover a particular need in my application, and it makes more sense to stick with typescript as a compiler. I may have to write about the project i'm doing in the future, but I dont think I want to go all full fp and endo functor the whole project from the ground up, you know (🥁 see what i did there lol).

Plus, its fun exploring new abstractions in new spaces.

Collapse
 
webbureaucrat profile image
webbureaucrat

For sure!

And yeah the named arguments syntax is pretty gross.

Thread Thread
 
wadecodez profile image
Wade Zimmerman

Nah
Image description

Collapse
 
frickingruvin profile image
Doug Wilson

Entertaining while demonstrating several interesting points. Nicely done, sir!

Collapse
 
gahuber95 profile image
Gary Huber

Very cool! Still, I wouldn't share it on a first date...

Collapse
 
aaronngray profile image
Aaron Gray

I was expecting them to be added to the language not constructed in the language.