Skip to content

Rust for C# Developers

If you write C# for a living, good news: a lot of Rust will feel familiar. Rust has generics with constraints, async/await, powerful pattern matching, iterator chains that read a lot like LINQ, and one excellent command-line tool (Cargo) that does what dotnet does. You already know how to think in types, interfaces, and lambdas. That knowledge carries over.

The big shift is about memory and errors. Rust has no garbage collector. Instead, every value has one owner, and the compiler checks ownership and borrowing before your program runs. Rust also has no null (you use Option) and no exceptions (you use Result). These three changes are where most of your learning time will go. This page maps what you know from C# and .NET to Rust, so you can spend that time on the parts that are really new.

If you want a general comparison with C++, Go, and Python instead, see Rust vs Other Languages.

Use this table as a phrase book. The last column links to the lesson on this site that explains the Rust side.

C# Rust Learn more
class with fields and methods struct for the data + an impl block for the methods Structs
interface trait Traits
Abstract class, inheritance No inheritance. Use composition (a struct inside a struct) + traits with default methods Traits, Design Patterns
List<IShape> of different classes Vec<Box<dyn Shape>> (a trait object) Trait Objects
Extension methods Your own trait, implemented for a type you didn’t write (like String) Traits
Generics with where T : IComparable<T> Generics with trait bounds: T: Ord or where T: Ord Generics
null, Nullable<int>, nullable reference types Option<T>: either Some(value) or None Option
?? and ?. .unwrap_or(...), .map(...), .and_then(...), and ? Option
Exceptions, throw, try/catch Result<T, E> + the ? operator Result, Custom Errors
An exception for a real bug (like InvalidOperationException) panic! (stops the thread) Panic
IDisposable + using Drop, which runs automatically when the owner goes out of scope (RAII) Scope, Smart Pointers
Garbage collector Ownership and borrowing, checked at compile time Ownership, Borrowing
record struct with #[derive(Debug, Clone, PartialEq)] Structs, Common Traits
with expression on a record Struct update syntax: Order { total: 5, ..old } Structs
enum (named integers only) enum where each variant can carry its own data Enums
switch expressions, is patterns match (the compiler checks you covered every case) match, Pattern Matching
Tuples (int, string) Tuples (i32, String) Tuples
ref / out parameters &mut T, or return a tuple or an Option Borrowing
Lambdas, Func<T>, Action<T>, delegates Closures, described by the traits Fn, FnMut, FnOnce Closures, Fn, FnMut & FnOnce
event A channel, or a list of boxed closures (callbacks) Channels, Fn, FnMut & FnOnce
LINQ: Where, Select, Sum, OrderBy, ToList Iterator adapters: filter, map, sum, sort_by (on a Vec), collect Iterators, Sorting
IEnumerable<T> and yield return impl Iterator<Item = T>, or your own type that implements Iterator Custom Iterators
async Task<T> / await async fn returns a Future; you need a runtime like tokio to run it Async/Await
Task.Run, Thread, lock std::thread::spawn or tokio::spawn; Mutex<T> wraps the data it protects Concurrency, Shared State
Properties (get; set;) No properties. Use pub fields, or getter and setter methods Structs
string and StringBuilder &str (a borrowed view of text) and String (owned, growable) Strings
String interpolation $"Hi {name}" format!("Hi {name}") and println!("Hi {name}") Output
var let (immutable by default; write let mut to allow changes) Variables
const, readonly const, and plain let (already read-only) Variables
static readonly field static, or LazyLock for values built at startup Variables
namespace and using directives Modules (mod) and use Modules
Assembly / project Crate (a library or a binary) Cargo & Crates
Attributes like [Serializable] Attributes like #[derive(Serialize)] and derive macros Macros, Serde & JSON
Reflection Mostly none at runtime. Macros and derive generate code at compile time instead Macros
unsafe and pointers unsafe blocks and raw pointers Unsafe Rust
Span<T>, ReadOnlySpan<char> Slices: &[T] and &str Slices
ToString() override, IFormattable Implement Display (then .to_string() works for free) Common Traits
IEquatable<T>, IComparable<T>, GetHashCode PartialEq/Eq, PartialOrd/Ord, Hash (usually derived) Common Traits
implicit / explicit conversion operators From/Into (and TryFrom when it can fail) Conversions
Operator overloading (operator +) Implement traits from std::ops, like Add Advanced Traits
checked / unchecked arithmetic checked_add, wrapping_add, saturating_add Integer Overflow
decimal No built-in decimal type; use the rust_decimal crate, or store cents in an integer Data Types

The standard library covers almost everything in System.Collections.Generic. Names change, ideas don’t.

C# Rust Learn more
List<T> Vec<T> Vectors
T[] (fixed length) [T; N] when the length is known at compile time, otherwise Vec<T> Arrays
Dictionary<K, V> HashMap<K, V> HashMaps
SortedDictionary<K, V> BTreeMap<K, V> (keys stay sorted) HashMaps
HashSet<T> HashSet<T> HashSet
SortedSet<T> BTreeSet<T> HashSet
Queue<T> VecDeque<T> (push_back / pop_front) VecDeque & BinaryHeap
Stack<T> Vec<T> with push / pop Vectors
PriorityQueue<TElement, TPriority> BinaryHeap<T> (largest first; wrap in Reverse for smallest first) VecDeque & BinaryHeap
LinkedList<T> LinkedList<T> exists, but you rarely need it; VecDeque is usually faster VecDeque & BinaryHeap
ConcurrentDictionary<K, V> Mutex<HashMap<K, V>>, or the dashmap crate Shared State

One thing to watch: like Dictionary, a Rust HashMap has no fixed order. If you print its contents, sort them first or use a BTreeMap.

This is the part most C# developers enjoy right away. Cargo does the jobs of the dotnet CLI, MSBuild, and NuGet in one tool.

.NET Rust Notes
dotnet CLI cargo Builds, runs, tests, adds packages, publishes. See Cargo & Crates
.csproj Cargo.toml Short and hand-editable
packages.lock.json Cargo.lock Created for you
.sln with several projects A Cargo workspace One Cargo.toml at the top lists the member crates
NuGet / nuget.org crates.io Every published crate’s API docs are on docs.rs
dotnet add package Serilog cargo add tracing
dotnet new console cargo new my_app (cargo new --lib for a library)
dotnet run / build / test cargo run / cargo build / cargo test
-c Release --release Debug builds are much slower at runtime; benchmark in release
dotnet watch cargo watch or bacon (installed with cargo install)
dotnet tool install -g cargo install
global.json (SDK version) rustup and a rust-toolchain.toml file
Roslyn analyzers cargo clippy Hundreds of helpful lints, official
dotnet format cargo fmt One standard style for everyone
Visual Studio / Rider VS Code + rust-analyzer, or JetBrains RustRover rust-analyzer gives completion, go-to-definition, and inline errors
XML doc comments /// /// doc comments in Markdown + cargo doc Code examples in docs are run as tests. See Documentation
xUnit / NUnit / MSTest Built-in #[test] and cargo test (cargo nextest is a popular faster runner) See Testing
Moq mockall, or hand-written fakes that implement your trait
Coverlet (code coverage) cargo llvm-cov or cargo tarpaulin
BenchmarkDotNet criterion See Performance
dotnet-trace, profilers cargo flamegraph, perf, or any native profiler

Here is a small Cargo.toml, so you can compare it with a .csproj:

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Libraries and frameworks: “What do we use instead of…?”

Section titled “Libraries and frameworks: “What do we use instead of…?””

This is the question every .NET team asks first. “We use Entity Framework. What do we have in Rust?” Here are the closest equivalents. Rust has no single company behind a “default stack” the way .NET has Microsoft, so you often pick between two or three good community crates.

.NET Rust Notes
Entity Framework Core Diesel: ORM and query builder; your queries are type-checked at compile time. Migrations with diesel_cli Mature (2.x), synchronous by default (diesel-async exists)
Entity Framework Core (async, entity-style) SeaORM: async ORM with entities, relations, and migrations (sea-orm-cli) Feels closest to EF; built on top of sqlx
Dapper sqlx: you write plain SQL, and the query! macro checks it against your real database at compile time. Migrations with sqlx-cli Async, very popular, not an ORM
ADO.NET sqlx, tokio-postgres, rusqlite (SQLite) Lower-level drivers
StackExchange.Redis redis

If you liked EF, try SeaORM first. If you liked Dapper and writing your own SQL, try sqlx. If you want the strongest compile-time guarantees, try Diesel. Rust Databases shows all three with working code.

.NET Rust Notes
ASP.NET Core Web API axum (we build one in Project: Web API), actix-web, Rocket axum and actix-web are widely used in production
Minimal APIs axum: routes are plain async fn handlers The style is very close
Middleware pipeline tower layers and the tower-http crate (CORS, compression, tracing, timeouts) axum uses tower directly
Built-in dependency injection State in axum, plain constructors, and traits See the DI row below
Swashbuckle / OpenAPI utoipa
SignalR WebSockets in axum (the ws feature), or tokio-tungstenite No SignalR-style protocol layer built in
gRPC (Grpc.AspNetCore) tonic (with prost for protobuf)
HttpClient reqwest Async by default, with an optional blocking client
JWT bearer auth jsonwebtoken
.NET Rust Notes
System.Text.Json / Newtonsoft.Json serde + serde_json The standard; also does TOML, YAML, and more. See Serde & JSON
ILogger / Serilog tracing (+ tracing-subscriber), or log + env_logger Structured logging with spans. See Logging
IConfiguration / appsettings.json config or figment Layer files and environment variables, then deserialize with serde
DI container (IServiceCollection) Usually nothing: pass dependencies into constructors, use traits for things you want to swap in tests shaku exists, but few projects use a container
AutoMapper Write From / Into implementations Explicit and checked by the compiler. See Conversions
FluentValidation / DataAnnotations validator or garde (derive-based validation)
Polly (retries) backon, or tower’s retry middleware
MediatR No direct equivalent Plain traits, or channels between tasks
Hangfire / background jobs tokio::spawn for in-process tasks; apalis for persistent job queues
CancellationToken Drop the future, or use CancellationToken from tokio-util
.NET Rust Notes
Blazor WebAssembly Leptos, Yew, Dioxus All before version 1.0; APIs still change. See WebAssembly
WPF / WinForms / MAUI Tauri (web UI + Rust backend), Slint, egui, Iced Tauri 2 and Slint 1.x are the most stable. No first-party GUI toolkit
Unity Bevy, or Rust inside Godot with the godot crate Bevy is pre-1.0 and breaks its API between releases
ML.NET candle, burn, linfa Much younger than Python’s ML tools
Azure SDK for .NET The official Azure SDK for Rust: azure_core, azure_identity, azure_storage_blob, … Covers fewer services than the .NET SDK

Side by side: a class becomes a struct + impl

Section titled “Side by side: a class becomes a struct + impl”

Let’s look at real code. Here is a small bank account in C#:

var account = new BankAccount("Ada", 100);
account.Deposit(50);
Console.WriteLine($"{account.Owner} has {account.Balance}");
public class BankAccount
{
public string Owner { get; }
private decimal balance;
public BankAccount(string owner, decimal balance)
{
Owner = owner;
this.balance = balance;
}
public void Deposit(decimal amount) => balance += amount;
public decimal Balance => balance;
}

And in Rust:

struct BankAccount {
owner: String,
balance: i64,
}
impl BankAccount {
// `new` is just a convention, not a keyword.
fn new(owner: &str, balance: i64) -> Self {
BankAccount { owner: owner.to_string(), balance }
}
// `&mut self`: this method changes the account.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// `&self`: this method only reads.
fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new("Ada", 100);
account.deposit(50);
println!("{} has {}", account.owner, account.balance());
}

Output:

Ada has 150
▶ Try it Yourself

Data and behavior live in two separate blocks. There are no constructors, only functions that return Self. Every method says up front whether it reads (&self) or changes (&mut self) the value. And because deposit changes the account, the variable must be let mut. In C#, any method can change any field; in Rust, changes are visible in the signature.

Side by side: an interface becomes a trait

Section titled “Side by side: an interface becomes a trait”

In C#, you might have an IShape interface with two classes and a List<IShape>:

var shapes = new List<IShape> { new Circle { Radius = 1 }, new Rectangle { Width = 2, Height = 3 } };
foreach (var s in shapes) Console.WriteLine($"{s.Area():F2}");
public interface IShape { double Area(); }
public class Circle : IShape
{
public double Radius { get; init; }
public double Area() => Math.PI * Radius * Radius;
}
public class Rectangle : IShape
{
public double Width { get; init; }
public double Height { get; init; }
public double Area() => Width * Height;
}

In Rust, the interface is a trait, and each struct implements it in its own impl block:

trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
// Box<dyn Shape> plays the role of "a reference to some IShape".
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 2.0, height: 3.0 }),
];
for shape in &shapes {
println!("{:.2}", shape.area());
}
}

Output:

3.14
6.00
▶ Try it Yourself

In C#, every interface call goes through a virtual call. In Rust you choose: Box<dyn Shape> uses a virtual call (see Trait Objects), while a generic function like fn print_area<T: Shape>(s: &T) is compiled separately for each type, with no virtual call at all. A nice extra: you can implement your own trait for types you didn’t write, even String or i32. That’s how Rust does what extension methods do in C#.

C# with nullable reference types warns you about null, but null still exists at runtime:

string? FindEmail(int userId) => userId == 1 ? "ada@example.com" : null;
var email = FindEmail(2);
if (email is not null) Console.WriteLine(email.ToUpper());
else Console.WriteLine("no email on file");
Console.WriteLine(FindEmail(2) ?? "unknown");

In Rust, a value that might be missing has the type Option<T>. You can’t use it as a plain value until you’ve handled the None case:

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("ada@example.com".to_string())
} else {
None
}
}
fn main() {
match find_email(1) {
Some(email) => println!("{}", email.to_uppercase()),
None => println!("no email on file"),
}
// `if let` is the short form when you only care about one case.
if let Some(email) = find_email(2) {
println!("found {email}");
} else {
println!("no email on file");
}
// unwrap_or works like C#'s ?? operator.
let email = find_email(2).unwrap_or("unknown".to_string());
println!("{email}");
}

Output:

ADA@EXAMPLE.COM
no email on file
unknown
▶ Try it Yourself

There is no NullReferenceException in Rust, because there is no null to dereference. The compiler simply won’t let you call .to_uppercase() on an Option<String>. Learn the helper methods on the Option page, and the short forms on if let.

Side by side: try/catch becomes Result and ?

Section titled “Side by side: try/catch becomes Result and ?”

In C#, parsing can throw, and you catch the exception somewhere up the call stack:

static int TotalSeats(string a, string b) => int.Parse(a) + int.Parse(b);
try
{
Console.WriteLine(TotalSeats("12", "30"));
Console.WriteLine(TotalSeats("12", "thirty"));
}
catch (FormatException e)
{
Console.WriteLine($"Error: {e.Message}");
}

In Rust, a function that can fail says so in its return type. The ? operator means “if this failed, return the error to my caller right now”. It does the job of letting an exception bubble up, but you can see it in the code:

use std::num::ParseIntError;
fn total_seats(a: &str, b: &str) -> Result<i32, ParseIntError> {
let first: i32 = a.parse()?;
let second: i32 = b.parse()?;
Ok(first + second)
}
fn main() {
for (a, b) in [("12", "30"), ("12", "thirty")] {
match total_seats(a, b) {
Ok(total) => println!("Total: {total}"),
Err(e) => println!("Error: {e}"),
}
}
}

Output:

Total: 42
Error: invalid digit found in string
▶ Try it Yourself

The big difference: in C#, any method might throw, and the signature doesn’t tell you. In Rust, Result is part of the signature, so callers can’t forget to handle it. For your own error types, see Custom Errors. In applications, the anyhow crate gives you something close to “catch any exception” (see anyhow & thiserror).

Side by side: LINQ becomes an iterator chain

Section titled “Side by side: LINQ becomes an iterator chain”

A typical LINQ query:

var orders = new List<Order> { new("Ada", 120), new("Grace", 80), new("Linus", 200), new("Ken", 150) };
var bigSpenders = orders
.Where(o => o.Total > 100)
.OrderByDescending(o => o.Total)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", bigSpenders));
Console.WriteLine($"Revenue: {orders.Sum(o => o.Total)}");
record Order(string Customer, int Total);

The Rust version uses iterator adapters and closures:

struct Order {
customer: String,
total: u32,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120 },
Order { customer: "Grace".to_string(), total: 80 },
Order { customer: "Linus".to_string(), total: 200 },
Order { customer: "Ken".to_string(), total: 150 },
];
// Where(...).ToList()
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// OrderByDescending(...): sorting happens on a Vec, not on the iterator.
big.sort_by(|a, b| b.total.cmp(&a.total));
// Select(...)
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// Sum(...)
let revenue: u32 = orders.iter().map(|o| o.total).sum();
println!("Revenue: {revenue}");
}

Output:

Linus, Ken, Ada
Revenue: 550
▶ Try it Yourself

Rust iterators are lazy, just like LINQ’s deferred execution: nothing runs until you call something like collect(), sum(), or a for loop. collect() is your ToList(). Unlike LINQ, iterator chains are usually compiled down to the same machine code as a hand-written loop, so you don’t pay for the nice style. Sorting is covered on the Sorting page.

Side by side: using/IDisposable becomes Drop

Section titled “Side by side: using/IDisposable becomes Drop”

In C#, you must remember using to release a resource on time:

using (var conn = new Connection("orders-db"))
{
Console.WriteLine($"querying {conn.Name}");
}
Console.WriteLine("after the block");
class Connection : IDisposable
{
public string Name { get; }
public Connection(string name) => Name = name;
public void Dispose() => Console.WriteLine($"closing connection to {Name}");
}

In Rust, cleanup is automatic. When the owner of a value goes out of scope, Rust calls its Drop implementation. There is nothing to forget:

struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("closing connection to {}", self.name);
}
}
fn main() {
{
let conn = Connection { name: "orders-db".to_string() };
println!("querying {}", conn.name);
} // `conn` goes out of scope here, so drop() runs.
println!("after the block");
}

Output:

querying orders-db
closing connection to orders-db
after the block
▶ Try it Yourself

This is called RAII, and it’s how Rust handles everything: files, sockets, locks, and memory itself. A file closes when its File value is dropped. A Mutex unlocks when its guard is dropped. There is no finalizer and no GC pause; cleanup happens at a predictable moment.

Things that will trip you up as a C# developer

Section titled “Things that will trip you up as a C# developer”

Everyone coming from C# hits the same bumps. Knowing them in advance helps.

  • Assignment moves, it doesn’t copy a reference. In C#, var b = a; for a class gives you two references to one object. In Rust, let b = a; moves ownership of a String or Vec into b, and a can’t be used anymore:

    fn main() {
    let owner = String::from("Ada");
    let copy = owner;
    println!("{owner} and {copy}");
    }
    error[E0382]: borrow of moved value: `owner`
    --> src/main.rs:4:16
    |
    2 | let owner = String::from("Ada");
    | ----- move occurs because `owner` has type `String`, which does not implement the `Copy` trait
    3 | let copy = owner;
    | ----- value moved here
    4 | println!("{owner} and {copy}");
    | ^^^^^ value borrowed here after move

    The fix is to borrow (let copy = &owner;) or to clone on purpose (owner.clone()). Read Ownership first; it explains this rule.

  • The borrow checker. You can have many readers (&T) or one writer (&mut T) at a time, never both. Patterns that are normal in C#, like objects that point back to their parent, or many objects changing one shared object, need smart pointers or a different design. Expect a few frustrating weeks. It gets much easier.

  • String vs &str. C# has one string type. Rust has two: String owns its text, &str borrows it. Functions usually take &str and structs usually store String. See Strings.

  • No inheritance. You can’t write a base class with shared fields and override methods. Use traits (with default methods) for shared behavior, and put shared data in a struct that other structs contain.

  • No exceptions. Errors are values. You’ll write ? a lot, and your main can return Result too. Calling .unwrap() is fine while learning, but it’s the Rust version of “ignore the exception and crash”.

  • No null. At first, Option everywhere feels like extra work. After a while you’ll notice that you’ve stopped getting null-reference bugs.

  • Explicit clones. Rust never copies a heap value behind your back. If you see .clone(), a real copy is made. That’s a feature: the cost is visible.

  • Compile times. A clean build of a project with many crates takes longer than a .NET build. Incremental builds and cargo check (which skips code generation) are much faster; use cargo check while you edit.

  • Async needs a runtime. In .NET, the runtime and thread pool are always there. In Rust, an async fn does nothing until an executor runs it, so you add tokio and put #[tokio::main] on main. See Async/Await.

You don’t need to read this site in order. As a C# developer, this route skips what you already know and spends time where Rust is different:

  1. Get Started: install Rust and run cargo new.
  2. Variables and Data Types: skim these, mostly to learn let mut and the number types.
  3. Ownership, then Borrowing, then Slices: the most important pages for you. Take your time.
  4. Strings: String vs &str.
  5. Structs and Enums, then match and Pattern Matching.
  6. Option and Result, then Custom Errors.
  7. Traits, Generics, Common Traits, and Trait Objects.
  8. Closures and Iterators: your LINQ.
  9. Vectors, HashMaps, and VecDeque & BinaryHeap.
  10. Modules, Cargo & Crates, and Testing.
  11. Lifetimes and Smart Pointers.
  12. Concurrency, Shared State, and Async/Await.
  13. Serde & JSON and Logging, then build the Web API project.

Both exercises give you a C# snippet. Port it to Rust.

Port this C# code. It should print the names of products that are in stock and cost less than 20. Hint: make a struct with #[derive(Debug, Clone, PartialEq)], then use filter, map, and collect into a Vec<&str>. Vec<&str> has a .join(", ") method.

var products = new List<Product>
{
new("Notebook", 5, true),
new("Headphones", 45, true),
new("Pen", 2, false),
new("Mug", 12, true),
};
var cheap = products.Where(p => p.InStock && p.Price < 20).Select(p => p.Name);
Console.WriteLine(string.Join(", ", cheap));
record Product(string Name, int Price, bool InStock);
Show solution
#[derive(Debug, Clone, PartialEq)]
struct Product {
name: String,
price: u32,
in_stock: bool,
}
fn product(name: &str, price: u32, in_stock: bool) -> Product {
Product { name: name.to_string(), price, in_stock }
}
fn main() {
let products = vec![
product("Notebook", 5, true),
product("Headphones", 45, true),
product("Pen", 2, false),
product("Mug", 12, true),
];
let cheap: Vec<&str> = products
.iter()
.filter(|p| p.in_stock && p.price < 20)
.map(|p| p.name.as_str())
.collect();
println!("{}", cheap.join(", "));
}

Output:

Notebook, Mug
▶ Try it Yourself

A small helper function plays the role of the record’s positional constructor. The derives give you what a C# record gives you for free: printing with {:?}, copying with .clone(), and comparing with ==.

This C# code takes one item out of a stock dictionary and throws when something is wrong. Port it so that take returns a Result<u32, String> instead of throwing. Hint: stock.get_mut(item) returns an Option<&mut u32>. Turn it into a Result with .ok_or(...), then use ?.

var stock = new Dictionary<string, int> { ["apples"] = 3, ["pears"] = 0 };
int Take(string item)
{
if (!stock.TryGetValue(item, out var count))
throw new KeyNotFoundException($"no item called {item}");
if (count == 0)
throw new InvalidOperationException($"{item} is out of stock");
stock[item] = count - 1;
return count - 1;
}
foreach (var item in new[] { "apples", "pears", "kiwis" })
{
try { Console.WriteLine($"took one {item}, {Take(item)} left"); }
catch (Exception e) { Console.WriteLine($"error: {e.Message}"); }
}
Show solution
use std::collections::HashMap;
fn take(stock: &mut HashMap<String, u32>, item: &str) -> Result<u32, String> {
let count = stock
.get_mut(item)
.ok_or(format!("no item called {item}"))?;
if *count == 0 {
return Err(format!("{item} is out of stock"));
}
*count -= 1;
Ok(*count)
}
fn main() {
let mut stock = HashMap::new();
stock.insert("apples".to_string(), 3);
stock.insert("pears".to_string(), 0);
for item in ["apples", "pears", "kiwis"] {
match take(&mut stock, item) {
Ok(left) => println!("took one {item}, {left} left"),
Err(e) => println!("error: {e}"),
}
}
}

Output:

took one apples, 2 left
error: pears is out of stock
error: no item called kiwis
▶ Try it Yourself

TryGetValue with an out parameter becomes get_mut, which returns an Option. The two throws become Err values, and the try/catch becomes a match. The &mut stock in the call makes it clear that take changes the dictionary.

  • Much of C# carries over: generics, async/await, lambdas, pattern matching, and LINQ-style iterators all have close Rust equivalents.
  • The big shifts are ownership instead of a garbage collector, Option instead of null, and Result + ? instead of exceptions.
  • Classes become a struct + impl, interfaces become traits, and inheritance becomes composition plus traits. using/IDisposable becomes automatic Drop.
  • Cargo replaces the dotnet CLI, MSBuild, and NuGet; clippy and rustfmt replace analyzers and dotnet format; testing is built in.
  • For EF Core look at SeaORM or Diesel, for Dapper look at sqlx, and for ASP.NET Core look at axum or actix-web. GUI, games, and ML options are younger.

Quick check

1. What does Rust use instead of null?

2. Your team uses Dapper and likes writing SQL by hand. Which Rust crate is the closest match?

3. What is the closest Rust equivalent of C#'s using block with IDisposable?

4. In C#, var b = a; with a class copies a reference. What does let b = a; do with a String in Rust?

Score: 0 / 4