Chapter 08 · writing it the Rust way
You've met the pieces. This chapter is the habits that turn "C# transcribed into Rust" into code a Rust reviewer would nod at — most of which is leaning harder on the compiler than C# ever let you.
The default mindset flips from C#. There, you pass references everywhere because objects are reference types and copying is the GC's problem. In Rust, design APIs to borrow their inputs (&T, &str, &[T]) and take ownership only when the function needs to store or consume the value. This is both faster (ch. 7) and a clearer contract: the signature tells the caller exactly what you'll do with their data.
Parameters: &str over String, &[T] over Vec<T>. Take the owned type only when you'll keep it. For maximum flexibility, accept impl AsRef<str> / impl IntoIterator — the rough equivalent of accepting the broadest interface instead of a concrete type.
This is the highest-leverage habit Rust rewards. Use enums (ch. 3) instead of a bag of nullable fields or boolean flags, so the compiler's match exhaustiveness forces you to handle every case. Where C# code drifts toward "if this string is set then that int matters," Rust pushes you to encode the rule in a type.
class Payment {
bool IsCard;
string? CardNo; // only if IsCard
string? Iban; // only if !IsCard
}
enum Payment {
Card { number: String },
Bank { iban: String },
}
// match must cover both — no invalid combo
A one-field tuple struct costs nothing at runtime (it compiles away) but buys you a distinct type. Wrapping u64 as UserId(u64) means the compiler rejects passing an OrderId where a UserId was wanted — the same safety you'd get from a C# readonly record struct, with zero overhead.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
fn load(id: UserId) { ... } // can't pass a raw u64 by accident
Result for the recoverable, panic for bugsLean on chapter 4. Return Result<T, E> and propagate with ? for anything a caller might reasonably handle. Reserve panic! (and .unwrap()) for genuinely-impossible states — the equivalent of an assertion failure, not an InvalidOperationException you expect to catch.
Avoid bare .unwrap() in code that ships. If a value really can't be absent, use .expect("why this is impossible") so a future panic explains itself. For error types: use the thiserror crate to define rich error enums in libraries, and anyhow for the catch-all error type in application/binary code.
From, get Into freeIdiomatic conversions go through the From trait — implement From<A> for B once and you get a.into() for free, and the ? operator will use it to auto-convert error types as it propagates. It's the Rust counterpart to implicit/explicit conversion operators, but more composable.
impl From<ParseError> for AppError {
fn from(e: ParseError) -> Self { AppError::Parse(e) }
}
// now `?` turns a ParseError into an AppError automatically
Two commands you should run constantly — they replace a wall of hand-written style rules and catch idiom slips a C# analyzer never could.
| Command | Does | C# analogue |
|---|---|---|
cargo fmt | canonical formatting, no config debates | dotnet format / EditorConfig |
cargo clippy | ~700 lints suggesting more idiomatic code | Roslyn analyzers, but stricter by default |
cargo test | runs unit + integration + doc tests | dotnet test |
Tests live next to the code in a #[cfg(test)] mod tests block (compiled only for tests), and your doc-comment examples are themselves run by cargo test — so documentation can't silently rot. Treat a clean cargo clippy as part of "done," the way you'd treat a green analyzer pass.