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.

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
- Three loop types and what each one does
- Structs: Rust’s three forms
- Enums that carry data
- impl blocks: attaching behavior to types
- What this changes about how you model state
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
ifexpression to a variable - You know that
loopcan return a value viabreak - You understand that
0..5excludes 5 and0..=5includes it - You know that Rust’s
foralways 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
Pentagonvariant toShapelater but forget to update thismatch, 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
- Control Flow — The Rust Programming Language, Ch. 3.5
- Using Structs to Structure Related Data — The Rust Programming Language, Ch. 5
- Method Syntax — The Rust Programming Language, Ch. 5.3
- Enums and Pattern Matching — The Rust Programming Language, Ch. 6
- The Option Enum and its Advantages Over Null Values — The Rust Programming Language, Ch. 6.1
- Rust Expressions — The Rust Reference