Chapter 08 · writing it the Rust way

Idiomatic Rust is mostly letting the types do the work.

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.

8.1Borrow first, own only when you must

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.

Rule of thumb

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.

8.2Make illegal states unrepresentable

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.

C#-style (looser)

class Payment {
  bool IsCard;
  string? CardNo;   // only if IsCard
  string? Iban;     // only if !IsCard
}

Idiomatic Rust

enum Payment {
    Card { number: String },
    Bank { iban: String },
}
// match must cover both — no invalid combo

8.3The newtype pattern over primitive obsession

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

8.4Errors: Result for the recoverable, panic for bugs

Lean 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.

Recommendation

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.

8.5Conversions: implement From, get Into free

Idiomatic 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

8.6Let the tooling enforce the rest

Two commands you should run constantly — they replace a wall of hand-written style rules and catch idiom slips a C# analyzer never could.

CommandDoesC# analogue
cargo fmtcanonical formatting, no config debatesdotnet format / EditorConfig
cargo clippy~700 lints suggesting more idiomatic codeRoslyn analyzers, but stricter by default
cargo testruns unit + integration + doc testsdotnet test
Habit

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.