Discovering Rust Enums: A TypeScript Comparison

November 27, 2023 / 6 min read

rusttypescriptenums

Enums used to be my preferred method for handling form keys and sets of named constant values in TypeScript until I discovered as const. Since that revelation, I've bid farewell to enums and haven't looked back - I simply coudn't find a compelling use case for them. However, delving into Rust's enums completely changed my perspective. They're nothing like TypeScript's enums; despite sharing the same terminology, they offer a unique and powerful experience.

In this blog post, I aim to share my insights into Rust enums and draw comparisons with TypeScript. While I'll cover the basics, I acknowledge there's wealth of functionalities in Rust enums that I haven't explored yet. Rest assured, once I delve into those aspects, I'll craft another blog post to share my findings. Now let's dive in!

Let's start with a simple example in TypeScript.

ts
enum Shape {
Circle,
Square,
}
function printShape(shape: Shape) {
switch (shape) {
case Shape.Circle:
console.log('Circle');
break;
case Shape.Square:
console.log('Square');
break;
}
}
printShape(Shape.Circle); // Circle

It's a pretty straighforward example, we've defined an enum called Shape with two variants: Circle and Square. The printShape function, in turn, takes a Shape parameter and utilizes a switch statement to print the corresponding name of the shape.

Now let's do the same in Rust.

rust
enum Shape {
Circle,
Square,
}
fn print_shape(shape: Shape) {
match shape {
Shape::Circle => println!("Circle"),
Shape::Square => println!("Sqaure"),
};
}
fn main() {
print_shape(Shape::Circle);
}

If you not familiar with match, consider it as Rust's intelligent equivalent of a switch statement for enums. It examines the type and performs distinct actions based on its findings. It's like JavaScript's switch statement, but on steroids.

Now you might be thinking, "what's the big deal bro? They're pretty much the same thing." But hold your horses, let me show you something interesting here.

Let's tweak the enums in both TypeScript and Rust by introducing a new shape—let's call it a Triangle.

ts
enum Shape {
Circle,
Square,
Triangle,
}

After we added the Triangle shape, notice that nothing will happen in the TypeScript version.

ts
// nothing will happen here, life goes on
function printShape(shape: Shape) {
switch (shape) {
case Shape.Circle:
console.log('Circle');
break;
case Shape.Square:
console.log('Square');
break;
}
}
// Nothing will get printed, no error, no warning, nothing, nada, nil, zilch.
printShape(Shape.Triangle);

But in the Rust version, we will get an error.

rust
fn print_shape(shape: Shape) {
match shape { // missing match arm: `Triangle` not covered
Shape::Circle => println!("Circle"),
Shape::Square => println!("Sqaure"),
};
}

If you have rust-analyzer installed in your editor, you will see the error right away; otherwise, you'll encounter them when you run the code. The compiler will vehemently point out that the Triangle shape isn't covered in the match statement, preventing oversight in handling new shapes and highlighting Rust's exhaustive enums. In contrast, TypeScript doesn't enforce exhaustive checking for enums by default. Omitting a variant in a switch statement won't trigger an error, allowing flexibility but introducting the risk of overlooking cases.

Great, now allow me to share another fascinating aspect I've discovered about Rust enums. To illustrate, let's delve into the following example.

rust
enum Shape {
Circle(f64), // Circle variant with radius
Square(f64), // Square variant with side length
Triangle(f64, f64, f64), // Triangle variant with three side lengths
}
fn main() {
let circle = Shape::Circle(5.0);
let square = Shape::Square(4.0);
let triangle = Shape::Triangle(3.0, 4.0, 5.0);
// you can access associated values like this
if let Shape::Circle(radius) = circle {
println!("Circle with radius {}", radius); // Circle with radius 5
}
}
  • The enum Shape now has three variants (Circle, Square, and Triangle). The Circle variant has an associated value of type f64 (floating-point number) representing the radius. The Square variant has an associated value of type f64 representing the side length. The Triangle variant has three associated values of type f64, representing the three side lengths.

  • In the main function, instances of the Shape enum are created. The circle variable represents a Circle shape with a radius of 5.0, the square variable represents a Square shape with a side length of 4.0, and the triangle variable represents a Triangle shape with side lengths 3.0, 4.0, and 5.0.

  • The if let statement is used to destructure the circle instance, extracting the associated value (radius) if it is a Circle. If it is, it prints a message indicating that it's a circle with a specific radius.

Embracing the capability to define enums with variants that carry associated values is a powerful and versatile feature in Rust. This empowers us to encapsulate multiple types of values within a single type. Pretty cool, isn't it? But hold on, there's an additional layer of intrigue to explore!

rust
enum Shape {
Circle(f64), // Circle variant with radius
Square(f64), // Square variant with side length
Triangle(f64, f64, f64), // Triangle variant with three side lengths
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Square(side_length) => side_length * side_length,
Shape::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt() // Heron's formula for triangle area
}
}
}
}
fn main() {
let circle = Shape::Circle(5.0);
let square = Shape::Square(4.0);
let triangle = Shape::Triangle(3.0, 4.0, 5.0);
println!("Circle area: {}", circle.area()); // Circle area: 78.53981633974483
println!("Square area: {}", square.area()); // Square area: 16
println!("Triangle area: {}", triangle.area()); // Triangle area: 6
}

Ok, that's a lot of code, let's break it down.

  • In Rust, impl is short for "implementation." It's used to define the methods associated with a particular type or trait. In the above example, impl Shape is declaring the implementation block for the Shape enum.

  • fn area(&self) -> f64 { ... } Inside the impl block, there is a method called area. This method takes a reference to self (an instance of Shape) and returns a floating-point number (f64). This method is what calculates the area based on the variant of the Shape enum.

  • match self { ... } The match statement is used to pattern match on the enum variant. Depending on whether the enum is a Circle, Square, or Triangle, it performs different calculations to determine the area.

So, in essence, the impl Shape block allows you to define methods that operate on instances of the Shape enum. The area method, in particular, encapsulates the logic for calculating the area of each type of shape. Now that's very cool!

I'm convinced that Rust's enums hold even more potential beyond my exploration. Just skimming the surface of Rust enums has revealed their remarkable power and enjoyable utility. I trust you found this post engaging. Until our next exploration!