Control Flow, Structs, and Enums in Rust

I walk through Rust's if/else expressions, three loop types, classic and tuple structs, data-carrying enums, and impl blocks — with comparisons to TypeScript interfaces and discriminated unions.

Control Flow, Structs, and Enums in Rust

Rust’s if looks exactly like TypeScript’s. Same keyword, same block structure, same conditional behaviour. You write it once and it feels familiar. Then you see this:

let label = if score > 90 { "high" } else { "normal" };

That compiles. The if returned a value and you assigned it directly. Try the same in TypeScript and the language stops you — if is a statement there, not an expression. You’d reach for a ternary instead.

That small difference signals something wider: Rust treats constructs as expressions that other languages treat as statements. Once you see that pattern, control flow, structs, and enums stop looking like isolated features and start looking like a consistent system.

Series: Part 5 of 12. Start with Part 1: Learning Rust as a TypeScript Developer · Previous: Variables, Data Types, and Functions in Rust

On this page

if/else as an expression, not just a branch

In TypeScript, if/else controls execution but doesn’t produce a value you can use on the right side of an assignment. When you need that, you reach for the ternary:

const label = score > 90 ? "high" : "normal";

In Rust, if is an expression. Both arms produce a value, and the whole thing can sit in a let binding:

let label = if score > 90 { "high" } else { "normal" };

The constraint: both arms must return the same type. If one arm returns "high" (a &str) and the other returns 42 (an integer), the compiler rejects it with a type mismatch error. There’s no implicit coercion. This makes the expression form safe — you know at a glance what type label will always hold.

One style note: Rust doesn’t require parentheses around if conditions. rustfmt removes them because if score > 90 { ... } is already unambiguous. You can write them, but they’ll disappear on the next format pass.

else if chains work exactly as you’d expect. The first true condition runs and the rest are skipped.

Three loop types and what each one does

Rust gives you three loop keywords, each with a different purpose. Coming from TypeScript you’ll recognise the goal of each one — but a few details are worth knowing.

loop — infinite, can return a value

loop runs until you explicitly break. The useful addition: break can carry a value, and that value becomes the result of the entire loop expression:

let mut attempts = 0;

let result = loop {
    attempts += 1;
    if attempts == 5 {
        break attempts * 2;
    }
};
// result is 10

In TypeScript you’d declare a variable before the loop, assign inside it, and read it afterward — three separate steps. Rust collapses that into one expression. It’s clean when retrying an operation until it succeeds and you want to use the final result.

while — condition-checked before each iteration

while works the same way as in TypeScript. The condition is evaluated before every pass, and execution stops when it’s false:

let mut n = 0;
while n < 5 {
    n += 1;
}

for — always works with an iterator

Rust’s for doesn’t use a three-part (init; condition; increment) form. It always iterates over something. Ranges are the most common input:

for i in 0..5 {
    println!("{i}");
}

0..5 is exclusive — it produces 0, 1, 2, 3, 4. Use 0..=5 to include 5. When you need both the index and the value, .enumerate() works like entries() in JavaScript:

let names = ["alice", "bob", "carol"];
for (i, name) in names.iter().enumerate() {
    println!("{i}: {name}");
}

Check this before moving on

  • You can assign the result of an if expression to a variable
  • You know that loop can return a value via break
  • You understand that 0..5 excludes 5 and 0..=5 includes it
  • You know that Rust’s for always takes an iterator, never a three-part increment expression

Structs: Rust’s three forms

Structs are the primary way to group related data in Rust. TypeScript handles this with interfaces and object types. Rust has three distinct struct forms, each for a different purpose.

Classic struct — named fields

This is the form you’ll use most often. It looks like a TypeScript interface, but the data is owned rather than annotated:

#[derive(Debug)]
struct User {
    username: String,
    email: String,
    active: bool,
}

let user = User {
    username: String::from("alex"),
    email: String::from("alex@example.com"),
    active: true,
};

The #[derive(Debug)] attribute generates a Debug implementation automatically, which lets you print the struct with {:?}. Without it, println! doesn’t know how to format your type.

The key difference from TypeScript: when you pass a User to a function without a reference, the function takes ownership and the original binding is invalidated. That’s the ownership rule from Part 3: Ownership and Borrowing in Rust Explained Simply in action. Pass &user to borrow it instead.

Tuple struct — positional fields

Tuple structs are named tuples. You access fields by position:

struct Color(u8, u8, u8);
struct Point(f64, f64);

let red = Color(255, 0, 0);
let origin = Point(0.0, 0.0);

let r = red.0;     // 255
let x = origin.0;  // 0.0

Color and Point are different types even though both hold numbers of the same kind. You can’t accidentally pass a Color where a Point is expected. This is the “newtype” pattern — type safety at API boundaries with zero runtime overhead.

Unit struct — no fields

A unit struct carries no data. It’s used as a marker type when implementing traits:

struct AlwaysEqual;

It’ll make more sense once you reach traits and generics. For now, knowing it exists is enough.

Enums that carry data

This is the section TypeScript developers most often underestimate. Rust enums are genuinely more powerful than TypeScript union types, and the difference matters.

In TypeScript, a discriminated union is a type-level construct. You add a kind field manually because at runtime JavaScript objects carry no type information:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number };

Rust enums are different. Each variant is a full type with its own structure, and the variant name is the discriminant — no manual tag required:

#[derive(Debug)]
enum Shape {
    Circle(f64),
    Rectangle(f64, f64),
    Triangle { base: f64, height: f64 },
}

Circle holds one f64 (the radius). Rectangle holds two. Triangle uses named fields. The compiler knows which variant it’s looking at from the name alone.

You handle every variant with match, and match in Rust is exhaustive. The compiler won’t build code that misses a case:

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rectangle(w, h) => w * h,
        Shape::Triangle { base, height } => 0.5 * base * height,
    }
}

Before you continue: If you add a Pentagon variant to Shape later but forget to update this match, what happens?

The compiler emits a “non-exhaustive patterns” error at every match that doesn’t handle Pentagon. That fires at build time, not at runtime. Every match across the codebase must cover the new variant before the code compiles — the compiler finds all the gaps for you.

The standard library’s Option<T> is an enum defined exactly this way:

enum Option<T> {
    Some(T),
    None,
}

A function returning Option<String> forces the caller to handle both the present and absent cases before they can use the value. You can’t accidentally dereference a None the way you can accidentally access undefined in TypeScript. The absent case is baked into the type, not hidden behind a runtime check.

impl blocks: attaching behavior to types

Methods in Rust live in a separate impl block, not inside the struct or enum definition. Data layout and behavior stay as distinct concerns.

struct Point {
    x: f64,
    y: f64,
}

impl Point {
    // Associated function — no self; called as Point::new(3.0, 4.0)
    fn new(x: f64, y: f64) -> Point {
        Point { x, y }
    }

    // Method — borrows self immutably; can read fields but not change them
    fn distance_from_origin(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }

    // Mutable method — borrows self mutably; caller needs a mut binding
    fn translate(&mut self, dx: f64, dy: f64) {
        self.x += dx;
        self.y += dy;
    }
}

let mut p = Point::new(3.0, 4.0);
println!("{}", p.distance_from_origin()); // 5.0
p.translate(1.0, 0.0);

Three signatures you’ll see everywhere:

Signature Reads data Modifies data How to call
fn method(&self) Yes No point.method()
fn method(&mut self) Yes Yes point.method() (requires mut binding)
fn function() -> Type N/A N/A Point::function()

You can write multiple impl blocks for the same type — Rust merges them. This is common when implementing traits, where each trait gets its own impl block to keep things easy to scan.

Enums work the same way:

impl Shape {
    fn is_round(&self) -> bool {
        matches!(self, Shape::Circle(_))
    }
}

matches! is a macro that returns true if a value fits a given pattern — a clean shorthand for simple boolean checks on enum variants.

What this changes about how you model state

Rust doesn’t have classes. What classes do in TypeScript — bundle fields and methods, create instances — Rust handles through structs, enums, and impl blocks working together. struct holds the data. enum models the variants. impl adds the behavior. No inheritance, no this binding surprises, no prototype chain to reason about.

The TypeScript habit of reaching for a class when you need a type with methods translates directly: define a struct for the data, write an impl block for the methods. The compiler keeps the connection honest — a method on User can’t accidentally touch Point fields because both types are verified at compile time.

One pattern worth adopting early: when a value has a fixed set of possible states, prefer an enum over a struct with optional fields. A function that returns Result<User, DbError> is more explicit than one that returns a nullable user and throws on failure. The match at the call site forces both outcomes to be handled — the success path and the error path — and the compiler ensures neither is ignored.

If you’ve been learning Rust alongside TypeScript, the type system has started showing you something useful: it doesn’t just check types. It checks ownership, exhaustiveness, and mutability simultaneously. Control flow, structs, and enums are where that system stops being abstract and starts being a practical tool.

Sources