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.)
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.
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."
Learning opportunity: Or at least that's what I told myself to justify the impending sleep deprivation.
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
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
};
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;
}
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> }
}
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) }
})
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...
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)
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.
Space is a waste: so who cares how much this might cost you. I thought space is supposed to be infinite anyways...
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?
A deeper understanding of type systems: And a newfound respect for language designers who do this for a living.
Improved problem-solving skills: If you can implement cross-language features, you can probably figure out world peace, am I right? Maybe?
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.