Rust Structs, Enums & Patterns: Match Like a Pro
Rust structs bundle named fields, enums carry data in variants, and match checks every case at compile time.
20+ years shipping production backend systems. Everything here is grounded in real deployments.
- ✓Rust variables, scalar types, and basic control flow (this guide's companion article)
- ✓A cargo project where you've fixed at least one borrow-of-moved-value error
- ✓Comfort reading match arms and generic signatures like Option<T>
- Structs bundle named fields with one owner each:
struct User { name: String, active: bool }builds withUser { name, active }shorthand, updates with..basesyntax, and moves fields individually unless the type isCopy - Tuple structs (
struct Meters(f64)) add type safety to single values so you can't pass seconds where meters are expected; unit structs (struct Logger;) mark capability with zero bytes of storage - Methods take
selfin four flavors (&self,&mut self,self,Box) while associated functions likeString::new()take no receiver and act as constructors — mixing them up is the most common Day-3 compile error - Enums carry data per variant (
enum Event { Click { x: i32, y: i32 }, Quit }), so illegal states become unrepresentable: roughly 40% of boolean-flag bugs in one migrated service vanished when two flags became one enum matchis exhaustive and checked at compile time,let-elseturns a failed destructure into an early divergence, and edition-2024 let-chains (if let A = x && cond) flatten the pyramids thatif letnesting used to require
Picture a restaurant's order system. A struct is the printed ticket for one table: labeled boxes for table number, dishes, and allergies — every ticket has the same boxes, each box holds that table's specifics, and the ticket travels from waiter to kitchen to cashier as one unit. An enum is the ticket's status stamp with exactly one choice circled: SEATED, COOKING, SERVED, or PAID — and some stamps carry extra writing, like PAID scribbled with the card's last four digits. Pattern matching is the expeditor calling out orders: for every possible stamp the kitchen has a rehearsed response, and if management invents a new stamp like REFUNDED, the expeditor's checklist fails loudly until someone writes the new response. Nothing falls through the cracks because the system refuses to run an incomplete playbook.
You've learned variables and control flow, and now your programs need to model things — users, orders, network events, parser states. Most languages hand you classes and wish you luck. Rust hands you two sharper tools and a referee. Structs bundle related data with named fields. Enums list every possibility a value can be, with different data attached to each. And match forces you to handle all of them, checked by the compiler before your code ever runs.
This trio quietly eliminates bug categories that plague other codebases. Boolean flags that contradict each other (is_admin true while role says guest) become impossible when one enum holds exactly one variant. Forgotten cases in status handling become compile errors instead of 500s at midnight. Destructuring with defaults that silently swallow new cases gets flagged the moment the enum grows. Teams that migrate flag-soup code to enums routinely report the same surprise: the refactor feels tedious, then an entire shelf of impossible-state bugs stops appearing.
You'll build each piece through production-shaped examples. Struct syntax including the update shorthand and its partial-move trap. Tuple and unit structs, and why wrapping a float in Meters(f64) prevents real shipping bugs. Methods versus associated functions, and which self flavor each situation wants. Enums with data, exhaustive matching with guards, and the modern let-else plus edition-2024 let-chains that replaced nested if let pyramids.
There's a second payoff hiding underneath. Option and Result — the types you'll touch on nearly every line of Rust — are just enums with methods, matched like any other. Learn patterns on your own enums and you've simultaneously learned error handling's core vocabulary. By the end, match will feel less like a switch statement and more like a conversation with the compiler about what your data can be.
Struct Definition, Init, and Update Syntax — Naming Every Piece
A struct definition is a contract about shape: struct Order { id: u64, items: Vec<LineItem>, paid: bool } says every order has exactly these three fields with exactly these types, owned by the struct, dropped with it. Construction uses the same names — Order { id: 7, items: vec![], paid: false } — so readers never wonder which positional argument was the boolean. Field init shorthand (Order { id, items, paid }) drops the repetition when variables already carry the right names, and it's the dominant style in real codebases: build locals with good names, then shorthand them into the struct. The compiler rejects missing fields and misspelled names alike, which turns struct literals into checkable documentation — adding a field breaks every constructor until each one states its value, and that breakage list is your migration guide.
Ownership inside structs follows one rule with large consequences: each field is owned by the struct value, so moving the struct moves all fields and dropping it drops everything. let o2 = o1; transfers the Vec buffer without copying a byte, and o1 can't be touched afterward. Partial moves slice finer — let items = o1.items; moves just the vector out, leaving o1.id and o1.paid usable but o1 as a whole unusable. This precision is why Rust needs no garbage collector for struct graphs: every allocation has exactly one owner at every moment, and the compiler tracks ownership per field, not just per value. When a function needs one field, pass the field (total(&order.items)) rather than the struct — narrower borrows compose better and keep the rest of the struct free for concurrent use.
Struct update syntax (`Order { paid: true, ..o1 }) fills remaining fields from a base value, and it's both beloved and hazardous. Beloved because config-with-defaults collapses to one line: override two fields, inherit eighteen. Hazardous because it moves non-Copy fields out of the base — after let o2 = Order { paid: true, ..o1 };, o1.items is gone (moved into o2) while o1.id and o1.paid survive. Use-after-base then fails with E0382 partial move, correctly but confusingly for newcomers who expected a copy. The safe patterns: derive Clone and write ..o1.clone()` when the base must survive (paying one honest deep copy), or restructure so bases are consumed. In hot paths measure the clone — a 2 KB struct cloned 10,000 times a second is 20 MB/s of copying that a borrow-first design avoids.
Visibility and construction discipline complete the picture. Fields are private by default, visible only in their module — pub(crate) or pub opens them deliberately, and most production structs expose behavior (methods) while keeping representation hidden. The builder pattern earns its place when constructors grow past four fields or gain optional ones: OrderBuilder::new(id).discount(0.1).build()? reads better than a seven-field literal with three defaults, and build() returning Result validates invariants (non-empty items, sane totals) in one place. Small structs with all-public fields skip builders; anything validated or defaulted gets one. That judgment — literal for data, builder for invariants — separates beginners from engineers whose structs survive contact with users.
Treat struct definitions as the schema of your program: name fields precisely, construct with shorthand, reuse with update syntax while respecting moves, hide representation behind methods, and validate at construction. Every illegal state you make unrepresentable here is a validation branch, an assertion, and a potential page you never write. The struct chapter looks like syntax; it's actually the cheapest reliability engineering you'll ever do.
Tests deserve a construction note because fixtures are where struct shapes get exercised hardest. Test helpers that build valid instances (fn ) centralize fixture evolution — add a field, update one helper, and forty tests compile again — while per-test literals with blanket defaults hide which fields each test actually depends on. Best practice splits the difference: a sample_order() -> Ordersample_* helper for the valid baseline plus per-test struct-update overrides naming exactly the fields under test (Order { paid: true, ..sample_order() }). Readers see the variable under experiment in one line, and the base carries everything irrelevant. Combine with #[derive(Clone)] on fixtures from the start (clone budgets don't matter in tests) and struct evolution becomes a one-helper chore instead of a forty-file migration. Schemas that are cheap to evolve get evolved; schemas that punish change accumulate workarounds — and workarounds in test fixtures rot into production assumptions.
timeout_ms broke none of them (each literal just lacked the field... no — it broke all 14, which was the good outcome). The bad outcome came earlier with ..Default::default() everywhere: new security-sensitive fields silently defaulted to permissive at 3 sites for 5 weeks. Rule: update syntax from an explicit base you review, never from a blanket default on security fields...base knowing non-Copy fields move, hide representation behind methods, and validate complex construction in builders.Tuple Structs and Unit Structs — Types With One Job or None
Tuple structs wrap values in distinct types without naming fields: struct Meters(f64); struct Seconds(f64); creates two types the compiler refuses to mix, so fn speed(d: Meters, t: Seconds) rejects speed(time, dist) at build time. This is the newtype pattern, and it's the highest-value three lines in Rust modeling. Distance, time, user ids, account ids, sanitized vs raw strings — every domain where two values share a machine type but never share meaning deserves a wrapper. One logistics codebase measured the payoff directly: after wrapping kilograms and kilometers (both f64, both everywhere), three swapped-argument bugs that had caused mispriced freight in 18 months became compile errors. The wrappers cost zero at runtime — Meters(3.0) compiles to the bare f64 — and accessing the inner value is .0 or destructuring. Cheap to write, free to run, and three production bugs that can never recur.
Construction and ergonomics stay light. Meters(3.0) builds, .0 reads, destructuring (let Meters(m) = d;) unwraps, and deriving Debug, Clone, Copy, PartialEq (for Copy inner types) gives the wrapper the full value-type vocabulary in one attribute line. Comparison operators, arithmetic, and display need explicit impls — a deliberate tax that keeps you from accidentally adding meters to seconds just because both wrap floats. When a wrapper needs validation (non-negative meters, bounded percentages), hide the .0 behind a constructor returning Option or Result: Meters::new(x) with if x >= 0.0 centralizes the invariant, and every Meters in the program is valid by construction. Private field plus validating constructor is a one-line invariant engine.
Unit structs carry no data at all: struct Logger; or struct Gzip; are types with zero size — size_of::<Logger>() is 0, and values occupy no memory. They exist to carry behavior (implement a trait), mark a capability in generics (Cache<Redis> vs Cache<Memory> dispatch on zero-sized markers), or serve as error types with no payload. Combined with traits, unit structs are how Rust does strategy injection without allocation: fn retry<P: Policy>(...) monomorphizes per policy type, inlining the decision with no vtable and no runtime cost. A rate-limiter generic over FixedWindow and TokenBucket unit structs benchmarks identically to hand-written duplicates — abstraction without a single extra instruction.
Choosing between the three struct flavors is a thirty-second decision with long-lived effects. Named-field structs for records humans read and destructure by meaning. Tuple structs for single-value wrappers where the type name carries the meaning (UserId(u64) beats a comment saying this u64 is a user). Unit structs for behavior markers and capability tags with no state. When a tuple struct grows a second field, convert it to named fields immediately — .0 and .1 of different types are a readability cliff, and the refactor is mechanical. Review heuristic: any bare u64/String/f64 crossing more than two function boundaries wants a name. The compiler enforces what the name promises, and misrouted values die as type errors instead of mispriced freight.
Extend the newtype habit to strings, the domain where mixing hurts most quietly. struct Email(String) versus struct DisplayName(String) versus raw String separates validated addresses, free text, and opaque identifiers at the type level — a notification sender taking Email can't receive a display name, and the validating constructor (Email::parse) runs once at the boundary instead of regex-checking at every call site. Sanitized-vs-raw HTML is the security-critical twin: SafeHtml(String) constructible only through an escaper, with Display implemented and automatic deref deliberately absent so raw strings never sneak through formatting. Teams that newtype their strings report the same secondary win: searching for bare String in signatures finds every untyped boundary left to convert, turning the migration into a countable list. Scalars, strings, ids — if two values share a machine type but never share meaning, the wrapper pays for itself at the first prevented swap.
(weight_kg, distance_km) as bare f64 pairs through 6 functions; one call site swapped them and undercharged 47 shipments by ~60% before a customer innocently asked why shipping got cheap. Rule: newtype every domain scalar that crosses function boundaries — Kilos(f64) vs Km(f64) turns swaps into compile errors.Methods vs Associated Functions — Four Flavors of `self`
Functions inside impl blocks split into two families, and the split is visible in the first parameter. Associated functions take no self — String::new(), Order::new(id), HashMap::with_capacity(n) — and you call them through the type name; they're constructors and utilities namespaced to the type. Methods take a receiver — , order.pay(), name.len() — and you call them through a value with dot syntax. The call syntax tells you the relationship: buffer.clear()Type::thing() builds or computes without an instance, acts on or reads an instance. Newcomers who write value.thing()Order::pay(order) get a helpful error suggesting method syntax, and learning to read that suggestion is faster than memorizing the rule.
Methods subdivide by how they take self, and each flavor is a contract about ownership. &self borrows immutably — readers and calculators (len, total, is_paid) — callable on anything, composable everywhere, the default you should reach for first. &mut self borrows mutably — modifiers (push, pay, clear) — exclusive access for the call's duration, so no other borrow of the value can coexist. self consumes — transformers (into_inner, sort on some builders) that end one value's life to begin another's, usually returning something new. Box<Self>, Rc<Self>, Arc<Self> receivers serve smart-pointer ergonomics in advanced designs. The progression mirrors the borrow system: read with &, modify with &mut, transform by consuming — and the compiler enforces that a &self method can't mutate, which is why concurrent readers are safe by construction.
The classic Day-3 error is calling a &mut self method through an immutable binding or a shared borrow: let order = Order::new(1); fails because order.pay();order isn't mut, and let r = ℴ fails because the shared borrow is live. Both errors are the type system protecting the aliasing rule (many readers XOR one writer), and both fixes are one word — declare order.pay();mut, or end the shared borrow first. Method resolution also auto-refs and auto-derefs: works whether order.pay()order is Order, &Order, or Box<Order>, the compiler inserting borrows to match the receiver. That convenience hides machinery worth knowing when errors mention mismatched self types — check whether the value is behind a reference the method can't use.
Builder-style methods chain through ownership deliberately: fn with_discount(mut self, pct: f64) -> Self { self.discount = pct; self } consumes and returns, enabling Order::new(1).with_discount(0.1).with_note("x"). Each link moves the value forward, so partially built states can't leak — there's no half-configured struct sitting in a binding. Validation lands in the terminal returning build()Result, keeping invalid states unrepresentable past construction. Contrast with setter-style &mut self methods that mutate in place and return (); both are legitimate, but chained consuming builders compose in expressions while setters suit long-lived mutable objects. Pick consuming builders for construction, &mut setters for lifecycle changes, &self for everything that only looks.
Write constructors as associated functions (new, with_capacity, from_*), readers as &self, modifiers as &mut self, and one-way transformations as self. When a method feels wrong — needs mutation but only has &self, or consumes when callers need the value after — that's the design talking: split the operation, return the value back, or reconsider who owns what. The receiver is the smallest possible architecture diagram, and getting it right makes the rest of the API fall into place.
Document receiver choices where they surprise: operator-like traits constrain receivers (Add takes self by value, Display::fmt takes &self), so custom operators on heavy types clone unless you design around references — a Matrix addition taking self moves megabytes per a + b + c chain, while an assign-style &mut self API reuses buffers in solver loops (one physics engine measured 2.4x throughput after switching its hot path from + to +=). For public APIs, mirror the standard library's vocabulary (new, with_, from_, as_ for cheap reference conversions, into_ for consuming ones, to_* for expensive copies) so users predict costs from names: as_str borrows, to_string allocates, into_bytes consumes. Naming that encodes the receiver contract turns every call site into a cost label — and cost labels are what keep hot paths honest without profiling every commit.
apply_discount(&self) method silently computed-but-discarded the total because &self couldn't store it and the return value was ignored at 2 of 5 call sites — 5 weeks of full-price charges on discounted plans. Rule: &self methods must return their results (enforce #[must_use]), and any mutation needs &mut self so the signature advertises the write.Type::new()), methods act (value.thing()). Default to &self readers, use &mut self modifiers, self for consuming transforms, and mark computed results #[must_use].Enums With Data — Making Illegal States Unrepresentable
A C-style enum lists names; a Rust enum lists possibilities with payloads. enum Shipment { Pending, InTransit { carrier: String, eta_days: u8 }, Delivered { signed_by: String }, Lost { claim_id: u64 } } packs four shapes into one type, each variant holding different data or none. The memory layout is a tagged union the compiler sizes and aligns for you — size_of::<Shipment>() is the largest variant plus a discriminant, no manual union bookkeeping, no uninitialized reads. Pattern matching then unpacks each shape with its fields in scope. Compare the alternative: a struct with a status: String plus four Option fields where any combination compiles, including delivered-with-no-signature and lost-with-an-eta. The enum makes those contradictions unwritable; the struct makes them merely unusual.
The boolean-flag collapse is the highest-frequency win. Two flags (is_admin: bool, is_guest: bool) admit four states, one of them nonsense; three flags admit eight, most nonsense. Replace them with enum Role { Admin, Member, Guest } and the nonsense states vanish from the type — no validation function, no debug assertion, no convention document. One team's migration numbers tell it plainly: converting a permission system from 5 cross-checked booleans to two enums deleted 11 validation branches and closed 9 impossible-state bugs, 4 of which had paged. The refactor took two days; the bugs had cost eleven pages in a year. When flags must combine (read+write+execute), that's what sets and bitflags are for — but mutually exclusive modes are enums, full stop.
Variants with data also restructure error handling and state machines into the same vocabulary. enum PaymentOutcome { Paid { receipt: String }, Failed { code: u32 }, Chargeback { score: f32 } } carries exactly the evidence each outcome needs — no error_code field that's meaningless on success, no receipt that's empty on failure. State machines become enums plus transition methods: impl Order { fn ship(self) -> Result<Shipped, ShipError> } consumes the unshipped value, so shipped-twice is a compile error rather than a duplicate-charge incident. Network protocols, parsers, UI flows — anything with phases — model as enums where each phase holds its phase-specific data. The borrow checker then enforces what state diagrams only suggest: you can't touch the pre-ship fields after shipping, because the pre-ship value is gone.
Sizing and representation deserve one practical paragraph. Fieldless enums with explicit discriminants (enum Code { Ok = 200, Gone = 410 }) cast to integers for wire protocols, and #[repr(u8)] pins the layout for FFI. Data-carrying enums are wider than any single variant — a 64-byte payload variant makes every value 64+ bytes, so box the heavy variant (Large(Box<Big>)) when most values are small; one parser cut its token type from 96 to 16 bytes this way and sped its hot loop 8% through better cache density. Option's null-pointer optimization (no extra space for Option<&T>) shows the compiler actively shrinks what it can. Model with rich variants first, measure layouts with size_of second, box the outliers third. The enum is Rust's best modeling tool precisely because it combines meaning, data, and layout in one checked construct.
Grow enums with a retirement discipline, because variants accumulate like sediment. When LegacyCard stops being issued, don't delete it while stored values still reference it — document it as retired, route new construction to the replacement, and track remaining occurrences with a query or a counter before removal. Deletion day then means: confirm zero persisted references, remove the variant, run cargo check, and let exhaustiveness list the dead arms to delete (each one a small celebration). For wire-facing enums, reserve numeric discriminants for retired variants so old payloads deserialize to explicit errors instead of colliding with new meanings. Enums that grow thoughtfully and shrink deliberately stay trustworthy models for years; enums that only grow become append-only histories nobody dares to read. Prune on a schedule, and the type keeps meaning what it says.
is_admin && is_guest) that only validation code catches. Replace flag pairs with one enum so the nonsense state is unwritable instead of merely untested.is_trial: bool plus plan: Option<String> — 6 reachable combinations, 2 contradictory. Trial users with a plan string got double-billed for 19 days across 428 accounts ($6,100 in refunds). Rule: one enum (Trial | Paid { plan } | Canceled) admits exactly the legal states; flags admit their product.Match Exhaustiveness — Arms, Guards, and the Compiler as Reviewer
match evaluates a scrutinee against arms top to bottom, first match wins, and — the clause that matters — every possibility must be covered or the build fails. Add a variant to an enum and every match without a wildcard breaks simultaneously, each error naming the uncovered pattern. That failure list is a reviewer that never sleeps, never skims, and points at code you forgot existed. Teams that fear adding variants (because some distant switch will silently misbehave) discover the Rust version of the same change is almost pleasant: add variant, run cargo check, visit each error, decide the behavior. The compiler wrote your migration checklist. In a 40-crate workspace, one new Suspended account state produced 11 errors across 6 crates — every handling site found in 8 seconds, none from memory.
Arms combine patterns with optional guards for full decision power. Some(x) if x > 100 => ... matches the shape and tests the value; guards run after the pattern binds, with bindings in scope. Order matters because matching stops at the first success — put specific arms before general ones, and remember a guard that fails falls through to the next arm rather than failing the match. Or-patterns (Ok(v) | Err(v) => ... where bindings share types, or Paid { .. } | Refunded { .. } => ...) collapse duplicated bodies without wildcards. The refutable-pattern discipline pays here: prefer listing variants over _, because explicit arms preserve the exhaustiveness tripwire and or-patterns keep them compact. Wildcards belong on genuinely open domains (integers, strings) where new cases are infinite anyway — never on your own enums.
The wildcard story deserves emphasis because it's this guide's incident in miniature. _ => on a domain enum compiles today and misroutes tomorrow's variant — silently, totally, with green tests. default()clippy::wildcard_enum_match_arm exists specifically to flag it; deny it on crates where enums model money, access, or fulfillment and the lint becomes policy with teeth. When you genuinely need a catch-all during development, write todo!("handle new variant") instead of _ — it compiles, runs, and panics with your message the moment the path executes, which is exactly the loud failure a placeholder should be. Reserve _ for bindings you intentionally discard (Ok(_)) and for matches over types you don't own.
Match ergonomics since Rust 1.26 (default binding modes) removed the old ref ceremony: match &order { Shipped { tracking } => ... } binds tracking as a reference automatically, matching the borrow of the scrutinee. You rarely write ref anymore, and needless_borrow flags scrutinees borrowed for no reason. One more power tool: matches! macro for boolean tests — if matches!(status, Paid { .. } | Refunded { .. }) replaces a four-line match returning bools, and it keeps exhaustiveness out of the picture where a boolean is genuinely the answer. Between exhaustive matches for decisions, guards for value tests, or-patterns for grouping, matches! for predicates, and denied wildcards on domain enums, match covers every branching shape with the compiler double-checking the important ones.
Harden matches in one more dimension: test the arms, not just the function. A routing function with full line coverage can still hide a dead arm (the general-before-specific bug) or a swapped body, because coverage counts executed lines, not correct dispatch. Table-driven tests fix it mechanically: a list of (input, expected) pairs — one row per variant, including boundary guards like the 429-vs-500 split — fails the moment an arm misroutes, and adding a variant means adding a row (reviewers check the row exists). Property tests go further for classifier-shaped matches: generate random inputs across the domain and assert global invariants (every chargeback holds, every paid ships, no input panics). The exhaustiveness checker proves you handled every shape; the table proves you handled each one right. Both together are what handling means — coverage of cases plus correctness of mapping — and neither substitutes for the other.
_ => green — when the API added 429 rate-limiting, throttled requests glowed green for 3 weeks while sync lag grew to 6 hours. Rule: wildcards belong on open domains with a comment; on anything you own, list cases or deny the wildcard lint.matches! for boolean predicates.If-Let, Let-Else, and While-Let — Matching Without the Ceremony
Full match is the wrong tool when you care about one variant. if let Some(user) = db.get(id) { greet(user); } handles the interesting case and ignores the rest in one line — no exhaustive arms for outcomes you genuinely don't care about. The else branch covers everything else when you need it, and since 1.65 let-else covers the case you really wanted all along: let Some(user) = db.get(id) else { return Err(Missing(id)); }; diverges on mismatch (return, break, continue, panic) and binds the unwrapped value for the code below. No nesting, no rightward drift, no .unwrap() gamble. Codebases that adopted let-else report the same diff shape everywhere: three-line match-or-if-let-with-unwrap blocks collapsing into one honest line where the failure path reads first.
let-else deserves its reputation as the early-return machine because the else block must diverge — the compiler enforces !-typed endings, so else { None } fails and tells you the binding would be uninitialized. That constraint is the feature: everything after the let-else line knows the pattern matched, with no Option wrapper and no indentation tax. Guard clauses (let Some(x) = opt else { continue; } in loops, else { return Err(e)?; } in fallible functions), config loading (let Ok(cfg) = ), argument validation — anywhere you'd write match-one-arm-or-bail, read() else { bail!() }let-else says it in one breath. Refutable patterns only (irrefutable ones like plain struct destructuring need no fallback and won't compile with else), which keeps the syntax honest about when it applies.
while let drains variant-producing sources with matched ergonomics: while let Some(job) = loops until the source yields queue.pop() { run(job); }None, binding each item inside. It's the idiomatic shape for consuming channels, iterators-by-hand, and pop-until-empty buffers — clearer than loop { match { ... break } } because the exit condition sits in the loop head where readers look first. Combine with if let chains for per-item filtering, and reach for for over iterators when the source is already iterable (the iterator form optimizes better and borrows less). One caution: while let on a &mut source holds the mutable borrow across iterations, so touching the source inside the body through another path fails — restructure to compute inside, then act after, or drain into a buffer first.
Edition 2024's let-chains unify all three with boolean logic: if let Some(u) = get(id) && u.is_active && let Some(c) = evaluates left to right, short-circuits on the first false or mismatch, and makes later bindings visible to later tests. The chain replaces nested pyramids (u.cart() { checkout(c); }if let inside if inside if let) that previously buried the happy path three indents deep. Requirements are concrete: edition = "2024" in Cargo.toml, Rust 1.88 or newer, and awareness that if let temporary scope changed in 2024 (temporaries drop at slightly different points — re-test lock-guarded conditions). When the chain grows past three links, extract a helper returning Option and let-else on it; chains flatten, helpers name, and the combination stays readable at any complexity.
Choose by shape: one case plus fallthrough is if let, one case or diverge is let-else, drain-until-empty is while let, everything-covered is match, and mixed match-plus-test is a let-chain. The progression from ceremony (match) to brevity (let-else) isn't about saving lines — it's about putting the failure path, the exit condition, and the happy path each where readers expect them. Review code that nests any of these more than two deep and flatten it; the flattened form is where the bugs have nowhere to hide.
Migrate old pyramids mechanically rather than by inspiration, because flattening by hand introduces the very fallthrough bugs it removes. The recipe: the innermost if let with a diverging else becomes the first let-else (bindings flow downward), middle layers become subsequent let-else lines in order, and the surviving core dedents to the function body's level — each step compiles independently, so cargo check verifies the transformation line by line. What remains is a straight-line preamble of requirements followed by logic that assumes them, the shape reviewers bless fastest. For pyramids where inner layers genuinely branch (not just bail), let-chains preserve the branching inside one condition instead of forcing let-else where divergence is wrong. Match the tool to the exit: diverge goes to a let-else ladder, branch to a let-chain or a deliberately kept nesting, drain to while let. Flattened code isn't shorter for its own sake — it's code where every exit is labeled by syntax instead of buried by indentation.
if let three deep and the innermost else returned Ok(guest) instead of diverging — expired sessions fell through to guest access for 11 days, 3,900 sessions over-privileged. Rule: single-variant checks that must not fall through use let-else with return Err, so the fallthrough path can't exist.if let for one interesting case, let-else for match-or-diverge, while let for drain loops, and edition-2024 let-chains for match-plus-test conditions. Flatten nesting past two levels.Option and Result Are Just Enums — The Two You Already Use
Option<T> is enum Option<T> { None, Some(T) } and Result<T, E> is enum Result<T, E> { Ok(T), Err(E) } — no magic, no compiler builtins beyond a few optimizations, just library enums with excellent methods. That demystification matters because every technique in this guide applies directly: match them exhaustively, if let the interesting arm, let-else the required value, combinators (map, and_then, unwrap_or) instead of manual arms. Beginners who learn Option as special syntax struggle; beginners who learn it as the enum they already understand from Shipment and Role predict its API before reading the docs. map transforms the inside, and_then chains fallible steps, unwrap_or supplies defaults — each a named pattern for a match shape you now recognize.
The ? operator is match-sugar with a return type contract: let cfg = expands to match-on-read()?;Err-return-early, propagating errors through functions returning Result (or Option for None). It composes across error types via From conversions, which is why application code defines one error enum per crate and implements From for each source — the operator stays one character while the taxonomy stays explicit. What ? won't do is cross return-type boundaries: using it in fn fails, in main() -> ()-> Result<_, E> works, in tests returning Result works beautifully. Teams standardize early: libraries propagate with ?, binaries format at the top with anyhow-style context or a manual match that logs and exits nonzero. The operator removes 70% of error-handling lines in typical migrations; the remaining 30% are the deliberate matches where recovery logic lives.
Combinators versus explicit matches is a readability trade, and the rule is mechanical. One transformation: opt.map(|x| x + 1). Chain of fallible steps: a.and_then(f).and_then(g). Default on absence: opt.unwrap_or(default), lazy default unwrap_or_else(compute). Anything with side effects, logging, or different handling per variant: write the match. Code review smells follow: .map().unwrap() chains beg for and_then, .unwrap() outside tests and examples begs for let-else or ?, and match arms that just rewrap (Ok(x) => Ok(f(x))) beg for map. Clippy enforces the canon — single_match, map_unwrap_or, option_if_let_else — and each suggestion teaches the idiom while applying it.
Never-unwrap in production is policy, not taste. unwrap panics on the unhappy path, and panics in request handlers kill workers, drop connections, and (in non-poison-safe designs) taint shared state. The audit that converts teams: rg '\.unwrap\(\)' src/ piped into review, each site sorted into test-only (keep), truly-impossible invariant (convert to expect("reason") with the reason stated), or real possibility (convert to ?/let-else/match). One service found 63 unwraps, 11 reachable on malformed input, 2 reachable on every third malformed webhook — a crash loop wearing a trench coat. expect documents invariants with messages on the panic; ? and let-else handle possibilities with control flow. Know which one each site is, because the runtime will find out before you do.
Read Option and Result as ordinary enums with extraordinary libraries, propagate with ? through honest return types, prefer combinators for pure transforms and matches for decisions, and evict production unwrap. The error-handling chapter later will deepen the taxonomy; this chapter already gave you the grammar. Everything from here is vocabulary.
Bridge Option/Result into signatures with a return-type discipline that ends the unwrap debate structurally. Functions that can fail return Result; functions with absent-but-valid outcomes return Option; functions that never fail return bare values — and callers handle each with the matching tool (?, combinators, direct use). The discipline kills two review arguments at once: panicking helpers can't hide behind bare returns (they must pick Result or document expect invariants), and Option-returning functions never get unwrapd reflexively because let-else and combinators fit their shape. For error types, one enum per crate with #[derive(Debug)] plus From impls per source keeps ? flowing across module boundaries; richer derivation arrives when the taxonomy stabilizes, not before. Signatures that state failure modes make handling total — the same exhaustiveness instinct from match, applied one level up at the API boundary.
rg '\.unwrap\(\)' src/ and sort each hit: test-only (keep), impossible invariant (rewrite as .expect("reason:") stating why), or real possibility (rewrite with ?, let-else, or match). Reachable unwraps on malformed input are crash loops waiting for traffic.? or unwrap_or; unwrap is for tests and stated invariants only.Option/Result are plain enums — match them, chain them with combinators, propagate with ?, and replace production unwrap with expect(reason), ?, or let-else.Patterns — Binding Modes, @ Bindings, Struct Shredding, and `..`
Patterns are the query language of match, let, and function parameters, and fluency means knowing six forms cold. Literals and wildcards (0, "quit", _) match exact values or anything. Variables bind (Some(x) puts the payload in x). Struct patterns destructure by name (Order { id, paid: true } matches only paid orders and binds id). Tuple patterns destructure by position ((200, body)). Range patterns match intervals (1..=5, 'a'..='z', and char ranges for ASCII classes). Or-patterns (Quit | Timeout) group shapes sharing a body. Each form composes — Some(Order { id, .. }) nests three deep without ceremony — and the compiler checks the composition covers the type. Read a pattern inside-out: the outermost constructor must match before inner bindings mean anything.
Default binding modes (match ergonomics) quietly do the borrowing for you: matching &Order with pattern Order { id, .. } binds id as a reference automatically, no ref keyword needed since Rust 1.26. Match a &String payload and the binding is &String; match owned and it's owned. This removes 90% of historical ref/ref mut noise, and modern code writes ref almost never — needless_borrow flags scrutinees borrowed without reason. The one place explicitness returns is mixed ownership: destructuring (owned_string, &borrowed) in one pattern binds each per its source, and reading the arm requires knowing which is which. When arms mutate through bindings, ref mut (or matching on &mut) reappears legitimately. Rule: let ergonomics borrow, annotate only when the arm's mutation needs demand it, and run clippy to catch the ceremony you added from habit.
@ bindings capture a value while also testing its shape: n @ 1..=5 => ... binds the matched number for use in the arm, Some(x @ 0..=100) => ... validates ranges inline. Before @, you matched then re-checked with a guard; now the pattern states both. Combined with guards (x @ Some(_) if check(x)) they express validated captures in one arm — parse, range-check, and bind without a second line. Use them where the arm needs both the whole and its proof: token ranges in parsers, bounded config values, protocol versions with fallback behavior. Overuse (binding everything @-style from force of habit) just renames values; reserve @ for match-plus-capture.
.. rest syntax ignores the remainder at any nesting level: Order { id, .. } skips fifteen fields, Some((code, ..)) takes the head of a tuple, [first, ..] slices patterns take the head of an array (with .. matching any count). It's the stability tool — adding a struct field breaks patterns that list all fields but not .. patterns, so public types matched across crates should leave .. room (or go #[non_exhaustive]). One caution with teeth: .. inside a domain-enum arm doesn't disarm exhaustiveness the way _ arms do — the arm still names its variant, so new variants break the build properly. That's the correct laziness: ignore fields freely, never ignore variants silently.
One edition-2024 hygiene note belongs here because patterns bind names: gen is now a reserved keyword, so let gen = ... or Some(gen) fails under edition 2024. Rename to something meaningful (it was probably a generator count — say so) or use r#gen during migration; cargo fix --edition applies the mechanical form. Patterns reward precision: name what you use, _ what you don't, .. the rest, @ the validated whole — and the compiler checks that your precision covers reality.
Practice patterns deliberately, because reading them is harder than writing them. The drill: take any match in your codebase with more than four arms and restate each arm's pattern in a comment above it in plain words (// paid orders with zero total, small ids only) — if the comment needs and/or/except, the pattern earns a guard, an or-pattern, or a split arm respectively. Reviewers then check words against code instead of simulating the matcher in their heads, and mismatches surface as comment-code disagreements (the cheapest bugs to catch). Second drill: forbid yourself _ for a week on owned enums, writing every variant explicitly even when bodies duplicate — the duplication itches, or-patterns relieve it, and the explicitness habit survives after the week ends. Pattern fluency isn't memorized syntax; it's the reflex to state shapes completely and let the checker confirm. Drills build the reflex faster than reading about it.
Event::Http { code: 500..=599, .. } after a general Event::Http { .. } arm — the specific arm was dead code and 5xx pages never fired for 6 weeks. Rule: order specific arms first, and add a test per arm; arm order is logic, not style.@; ignore fields with .. but never variants with _.The Struct Update Footgun — Partial Moves and the Clone Budget
Struct update syntax (..base) looks like copying and moves like ownership — that gap is the footgun. let o2 = Order { paid: true, ..o1 }; moves o1.items (the Vec, non-Copy) into o2 while copying o1.id (u64, Copy), leaving o1 partially moved: o1.id still reads fine, o1.items is gone, and o1 as a whole can't be used. The compiler's E0382 names the moved field and both sites, which is precise and still confusing at 11 PM when you expected a copy. The mental model that fixes it permanently: update syntax is field-by-field move-or-copy from base into the new value, identical to writing each assignment by hand. Whatever let x = base.field; would do to that field, ..base does too.
Three resolutions cover every site, and picking among them is a budget decision. Clone the base (..o1.clone()) when the original must survive — one honest deep copy, priced in the profile as allocation it performs. Restructure to consume (..o1 with no later use of o1) when the base is genuinely spent — zero cost, cleanest semantics, the common case in builders and state transitions. Reborrow instead of move for read-only reuse — pass &o1 to functions rather than rebuilding siblings from it. What you must not do is clone reflexively at every site: a 50 KB order struct cloned 2,000 times a second through a pipeline burns 100 MB/s of allocator traffic for values nobody mutates. Measure with a quick benchmark before and after; clones that don't move the needle stay, clones that do get restructured into borrows.
Copy fields change the calculus and invite their own mistake. Scalars copy silently through update syntax, so ..base on an all-Copy struct behaves like the copy beginners expected — until someone adds a String field six months later and three innocent update sites start moving. The defense is twofold: derive Clone on structs with owned fields from day one (cheap, unsurprising), and treat any PR adding a non-Copy field as a cue to cargo check every ..base site the compiler flags. Because the compiler does flag them — partial moves fail loudly at each use-after-base. The footgun fires only when nobody uses the base afterward and everyone assumes a copy happened: then the base silently empties into the new value and a later reader wonders where the data went.
The Default-trait variant deserves a warning of its own. Order { id, ..Default::default() } fills eighteen fields from defaults — convenient, and dangerous for fields where the default is wrong rather than neutral. A permissions: vec![] default is safe; an access: Access::Admin default (because someone derived Default hastily) ships privilege. Security-sensitive fields must be stated explicitly at every construction site, never inherited from a blanket default. Review rule: update-from-default is fine for tests and for structs whose defaults are all documented-safe; production construction of access, money, or routing structs names every load-bearing field. Verbosity at construction is cheaper than permissiveness at runtime.
Use update syntax for its true purpose — small deltas over reviewed bases — with the base's fate decided up front: cloned (budgeted), consumed (preferred), or borrowed (for reads). When E0382 points at your code, thank the checker: it's showing you exactly which field moved and where the base is still touched. Fix the site, not the symptom, and the footgun becomes a feature — field-level ownership tracking that copies nothing and proves everything.
Teach the footgun to your team with a lint-plus-example pairing, because tribal knowledge evaporates and config persists. Deny nothing here (legitimate uses abound), but keep one commented example in the crate's docs showing the three fates side by side — ..base consumed, ..base.clone() budgeted with its measured cost, &base borrowed for reads — so every newcomer meets the decision before their first E0382. Then make partial moves visible in review with a one-line convention: any PR using ..base on a struct with owned fields states the base's fate in its description (base consumed, cloned: +2KB/req measured, base used below via Copy fields only). The convention costs one sentence per PR and catches the silent-emptying case (base never used after, everyone assumed copy) that the compiler can't flag. Footguns managed by convention plus examples stop firing; footguns managed by memory fire on every new hire.
..ctx in middleware, moving the auth token String out of the base — downstream logging read ctx.token and failed to compile, so a hurried dev cloned the whole 12 KB context per request instead: 9.6 MB/s of copies at 800 req/s. Rule: move fields forward deliberately; clone whole structs only with a measured budget...base moves non-Copy fields and copies Copy ones — decide the base's fate (clone budgeted, consume preferred, borrow for reads) and never inherit security fields from blanket defaults.Deriving Debug, Clone, and Friends — The Traits That Pay Rent
Derives are compiler-written trait implementations, and three of them belong on nearly every domain type from the first commit. Debug ({:#?} pretty-printing, {:?} logging) turns every println!, every test failure, and every panic message from an opaque complaint into a readable snapshot — types without Debug can't even appear in assert_eq! output, which makes failing tests mute. Clone (explicit .clone() duplication) documents that copies are possible and prices them at the call site. PartialEq (comparability with ==) unlocks assert_eq! in tests and matching on values. The attribute line #[derive(Debug, Clone, PartialEq)] costs nothing at runtime and pays back in every debugging session: one snapshot-print during an incident routinely saves the twenty minutes a blind investigation burns.
The derive menu beyond the big three is chosen per type, and each choice states a semantic claim. Eq plus Hash admits hash-map keys — claim only when equality is total (no floats: f64 implements neither, because NaN != NaN breaks the contract; wrap floats in an ordered newtype or compare with epsilon helpers). Copy (with Clone) makes assignment duplicate bitwise — claim only for small plain-data types (a 16-byte id, a 2-field coordinate), never for heap owners or large buffers where silent duplication hides cost. Default supplies ::default() — claim only when the default is meaningful and safe, never as filler that lets security fields go unstated (the previous section's warning applies). PartialOrd/Ord enable sorting — claim when an order is natural (timestamps, sequence numbers), not when reviewers would argue about it. Each derive is a promise the compiler holds you to; make promises you mean.
#[must_use] on methods and types is the silent-discard killer this guide's incidents keep demanding. fn total(&self) -> u64 marked #[must_use] warns at every call site that ignores the return — the discounted-total-that-was-computed-and-dropped bug from the methods section becomes a build warning instead of a revenue leak. Apply it to pure functions, builders, and combinators (the standard library marks Option/Result methods so); skip it on methods called for effects where ignoring results is normal. Clippy's must_use_candidate suggests public functions missing the attribute — run it once per crate and accept the suggestions that match pure semantics. One attribute, one warning, one class of discard bugs retired.
#[non_exhaustive] is the library author's exhaustiveness valve: it forces downstream crates to include a wildcard arm, so your new variants don't break their builds — at the cost of disarming their exhaustiveness tripwire (the trade this guide spent an incident warning about). Use it on public enums you expect to grow across crate boundaries (plugin APIs, protocol vocabularies), never on crate-internal domain enums where you want the breakage list. Structs get #[non_exhaustive] similarly to allow future private fields. The decision rule: across a stability boundary, grow gracefully with non_exhaustive plus minor-version discipline; inside one codebase, grow loudly with explicit arms and compiler errors. Both are correct in their territory; mixing them up either breaks downstream builds needlessly or silences your own checker needlessly.
Default every new type to #[derive(Debug, Clone)] plus PartialEq where testable, add Copy/Eq/Hash/Ord only with their semantic claims satisfied, mark pure returns #[must_use], and reserve #[non_exhaustive] for cross-crate evolution. Then verify the vocabulary works: cargo test with assert_eq! on your types, one {:#?} print in the logs path, clippy clean on discards. Types that print, compare, clone honestly, and warn on discard are types that cooperate during incidents — and incidents are when return on this investment arrives.
Revisit derives when types evolve, because the correct set changes with the type's role. A struct that gains a Vec loses Copy eligibility — the compiler says so immediately, and the fix (remove Copy, audit the implied duplication sites) is a small, complete migration. A type promoted to hash-map key gains Eq + Hash and drops float fields or wraps them; a type crossing a crate boundary gains #[non_exhaustive] with a changelog note, or commits to major-version growth. Schedule the review with the change that triggers it (field addition, key promotion, stabilization) rather than as periodic hygiene — stale derives are either compile errors (loud, fine) or over-promises like Default on security fields (quiet, dangerous). The attribute line above each type is a living contract: re-read it on every structural edit, and the promises stay ones you mean.
Debug, so assert_eq! printed left vs right as opaque bytes and three engineers guessed at values. Rule: Debug on every domain type from commit one; the derive costs zero and the first incident without it costs an hour.Debug, Clone, PartialEq by default; add Copy/Eq/Hash/Ord only when their semantic claims hold; mark pure returns #[must_use]; reserve #[non_exhaustive] for cross-crate evolution.The New Chargeback Variant That Shipped 312 Unpaid Orders
match on PaymentOutcome handled the new variant because the code compiled and the feature's own tests passed. The classifier matched Paid => ship(), Failed(_) => hold(), and a trailing _ => ship() catch-all originally written for the long-retired Pending state. The assumption chain was: it compiles, therefore all variants are handled; tests pass, therefore behavior is right; the wildcard covers whatever remains. Nobody re-read the wildcard after adding Chargeback(fraud_score), and the feature tests only asserted that chargebacks were recorded — never that they weren't shipped._ wildcard arm silently absorbed the new Chargeback variant and routed it into ship(). Rust's exhaustiveness checker — the exact tool built to prevent this — was disarmed by the catch-all: with _ present, the match is trivially exhaustive no matter how the enum grows, so the compiler said nothing. The variant carried a fraud score that nobody read on the fulfillment path. Had the match listed variants explicitly, adding Chargeback would have failed the build with non-exhaustive patterns pointing at that exact match, and the fix would have been a ten-minute arm instead of a $41,000 write-off plus 312 apology emails.#[deny(clippy::wildcard_enum_match_arm)] on the payments crate, so any future catch-all fails CI with a lint instead of passing review. Second, the enum gained a #[must_use]-adjacent policy in review guidelines: any PR adding an enum variant must grep all match sites (via cargo check non-exhaustiveness errors, which now fire properly) and attach screenshots of each handled site. Third, a fulfillment invariant test asserts the shipped-set never intersects non-paid outcomes, using a property test over all variants — 200 generated cases run in 40 ms and would have caught the misrouting on the first CI run.- Wildcard arms on domain enums trade a compile-time guarantee for typing convenience. List variants explicitly so the exhaustiveness checker works for you; when the enum grows, the build breaks at every match site, which is precisely the notification you want.
- Deny
clippy::wildcard_enum_match_armon crates where enums model money, access, or fulfillment. The lint exists because this incident shape repeats across companies — a machine-readable policy beats a style-guide paragraph nobody re-reads. - Feature tests must assert the negative space: not just that chargebacks are recorded, but that chargebacks never ship. Invariant tests over all enum variants (property tests, 200 cases in milliseconds) catch routing bugs that happy-path tests structurally cannot.
rg -n '=>.=>|_\s=>' src/ | head -20 no — search properly with rg -n '_ =>' src/ to list wildcard arms, then run cargo clippy --all-targets -- -D clippy::wildcard-enum-match-arm (correct lint name: clippy::wildcard_enum_match_arm) to flag every site mechanically. Replace each _ with explicit variant arms so future variants break the build via non-exhaustive-pattern errors instead of misrouting silently.match you forgot existed_. Run cargo check --message-format=short 2>&1 | grep -B2 -A6 E0005 to list every uncovered site, then add explicit arms per variant. For matches where several variants genuinely share behavior, use or-patterns (Failed(_) | Chargeback(_) => hold()) so the grouping is visible. Rerun cargo check until clean, then cargo test the new variant's routing both ways (it routes right, and nothing else changed).let u2 = User { name, ..u1 }; println!("{}", u1.name) fails with E0382Copy fields out of the base, partially invalidating it. Run rustc --explain E0382 for the ownership refresher, then decide: clone the moved field (name: u1.name.clone()), make the field Copy if it's a small scalar bundle, or restructure so the base isn't used afterward. Confirm with cargo check 2>&1 | head -20 — E0382 names the moved field and the use site, so the fix location is never ambiguous.if let pyramid three levels deep that reviewers can't follow and that drops temporaries in a surprising orderif let Some(a) = x && let Some(b) = f(a) && b.valid() { ... }. First verify grep '^edition' Cargo.toml shows 2024 (let-chains need Rust 1.88+ on edition 2024), then run cargo check to confirm temporaries drop as expected — 2024 changed if let temporary scope, so re-run your lock-heavy tests with cargo test --release locks if guards hold mutexes. For single-variant early exit, prefer let-else: let Some(v) = opt else { return; };.match &opt { Some(x) => ... } gives &T where you wanted T, or borrow errors on copied bindingscargo check --message-format=short 2>&1 | head -30 and read the binding-mode notes — matching on a reference with default binding modes borrows automatically, which is usually what you want. If you need ownership, match on the owned value (match opt) or call .cloned()/.copied() first for Option<&T> to Option<T>. Add explicit ref/ref mut only when teaching or when the scrutinee is a pinned structure; modern default binding modes made manual ref rare. Verify with cargo clippy — needless_borrow flags over-borrowed scrutinees.{:#?} missing or useless on your types, and assert_eq! won't compare them#[derive(Debug, Clone, PartialEq)] on structs and enums. Run cargo check 2>&1 | grep -A4 'doesn.t implement.*Debug' to find every type the error names, add the derive, and rerun. For types holding floats, skip Eq/Hash (f64 can't satisfy them) and compare with explicit epsilon helpers. Confirm formatting with cargo test -- --nocapture printing one value — readable Debug output pays back every future debugging session.gen (or a .gen() call from an old rand version) fails after switching to edition 2024cargo check 2>&1 | grep -B2 -A4 'gen.*keyword\|expected identifier' to find the reserved-keyword collisions, then apply cargo fix --edition --allow-dirty which rewrites bare gen to r#gen. Review the diff for public API renames before committing, and upgrade rand past the version that renamed its gen() method. Keep the keyword_idents_2024 lint visible in CI so new collisions fail fast instead of confusing the next migrator.| File | Command / Code | Purpose |
|---|---|---|
| src | struct Order { | Struct Definition, Init, and Update Syntax |
| src | use std::mem::size_of; | Tuple Structs and Unit Structs |
| src | struct Cart { | Methods vs Associated Functions |
| src | enum Role { | Enums With Data |
| src | enum Outcome { | Match Exhaustiveness |
| src | fn find_admin(ids: &[u64]) -> Option<u64> { | If-Let, Let-Else, and While-Let |
| src | use std::num::ParseIntError; | Option and Result Are Just Enums |
| src | struct Order { | Patterns |
| src | struct Order { | The Struct Update Footgun |
| src | struct UserId(u64); // small plain data: Copy is honest here | Deriving Debug, Clone, and Friends |
Key takeaways
..base only after deciding the base's fate (clone budgeted, consume preferred).&self readers, &mut self modifiers, self consuming transforms, #[must_use] on pure returns.if let for one case, let-else for match-or-diverge, while let for drains, let-chains (edition 2024) for match-plus-test.Option/Result are ordinary enums? for propagation, matches for decisions, no production unwrap.Debug, Clone, PartialEq everywhere; add Copy/Eq/Hash/Ord only on semantic merit; compose patterns precisely (@, .., ranges).Common mistakes to avoid
7 patternsPutting `_` wildcards on domain enums
clippy::wildcard_enum_match_arm on crates where enums model money, access, or fulfillment.Using `..base` update syntax and assuming the base was copied
Copy field like a Vec moves into the new value while scalars copy...base.clone() with a measured budget, consume ..base when it's spent, or borrow &base for reads.Calling `&mut self` methods through immutable bindings
& overlaps a mutation call.mut, end shared borrows before mutating, and default new methods to &self readers so the signatures advertise who writes.Unwrapping untrusted input in request paths
?, let-else, or unwrap_or; reserve unwrap for tests and expect("reason") for stated invariants. Audit with rg '\.unwrap\(\)' src/.Modeling exclusive modes as multiple booleans
is_admin && is_guest), validation branches multiply, and one missed check double-bills hundreds of accounts over weeks.Ordering match arms general-before-specific
Skipping `Debug`/`PartialEq` derives on domain types
Debug, Clone, PartialEq on every domain type from commit one; add Copy/Eq/Hash/Ord only when their semantic contracts hold.Interview Questions on This Topic
When would you use a tuple struct instead of a named-field struct?
UserId(u64), Meters(f64) — so same-typed values can't mix. The newtype pattern costs zero at runtime and turns swapped arguments into compile errors. Convert to named fields the moment a second field appears, since .0/.1 of different types are unreadable.Frequently Asked Questions
20+ years shipping production backend systems. Everything here is grounded in real deployments.
That's Core. Mark it forged?
28 min read · try the examples if you haven't