Binding a name with let
In every language you have written, the first thing you do is give a value a name. In Go that is x := 5 or var x int = 5; in Python it is x = 5; in TypeScript const x = 5. Rust’s version is the let keyword, and it does what you expect with one twist worth meeting up front. Read this program and press Run:
// let binds a name to a value. By default the binding is immutable.
fn main() {
let port = 8080; // type inferred as i32
let host = "127.0.0.1"; // type inferred as &str
println!("serving {host} on port {port}");
// The compiler can infer most types, but you can always annotate one
// when you want to be explicit or when inference needs a hint.
let max_retries: u8 = 3;
let timeout_secs: f64 = 1.5;
println!("retries up to {max_retries}, timeout {timeout_secs}s");
// This line would not compile: port is immutable, so reassigning it
// is an error (cannot assign twice to immutable variable `port`).
// port = 9090;
}A binding(Rust’s word for a named value) is created with let name = value. The compiler reads the value and works out the type for you: 8080 is an i32, "127.0.0.1" is a string slice (&str). This is type inference, and it means you rarely write types on local bindings even though Rust is strictly, statically typed underneath. When you do want to be explicit, or when the compiler needs a nudge, you annotate after a colon: let max_retries: u8 = 3.
The twist is the commented-out line at the bottom. A plain let binding is immutable: once port names 8080, you cannot reassign it to 9090. Uncomment that line and the program will not build; the compiler says cannot assign twice to immutable variable. This is the opposite default from almost everywhere else you have worked, where a variable is mutable unless you go out of your way to freeze it (Go’s const, TypeScript’s const). In Rust, unchanging is the normal case and you ask for change explicitly. The next section is how you ask.
let x = v names the value v as x, inferring the type. Unless you say otherwise, that name cannot be reassigned. Coming from Go, read let as := that also locks the binding, the inverse of the language defaults you are used to.
Two ways to change: mut and shadowing
If let is frozen by default, you need a way to thaw it, and Rust gives you two that look similar and behave very differently. Confusing them is the classic early stumble, so this section pulls them apart. Run it first:
// Two ways a name's value can change. They are not the same mechanism.
fn main() {
// mut: one binding, one type, value allowed to change in place.
let mut counter = 0;
counter += 1;
counter += 1;
println!("counter is {counter}"); // 2
// Shadowing: a brand new binding that reuses the name. The old binding
// still existed; this one hides it from here on. The new binding can
// even have a different type, which mut could never do.
let input = " 42 "; // input is a &str
let input = input.trim(); // a new &str, the trimmed slice
let input: i32 = input.parse().unwrap(); // a new i32, parsed from it
println!("parsed to {}", input + 1); // 43
// Shadowing also lets you reshape a value without a mut binding, which
// keeps the final name immutable for the rest of the function.
let area = 3 * 4;
let area = area * 2; // a fresh, still-immutable binding
println!("area is {area}"); // 24
}The first tool is mut. Write let mut counter = 0 and you have one binding whose value is allowed to change in place: counter += 1 mutates the same slot, the same way a normal variable does in Go. The type is fixed for the life of the binding; only the value moves.
The second tool is shadowing, and it is not mutation at all. Each let input = ... creates a brand new binding that happens to reuse the name input, hiding the previous one from that point forward. Because it is a new binding, it can have a different type: input starts as a &str, becomes a trimmed &str, then becomes an i32 after parse. A mut binding could never do that; reassigning counter to a string would be a type error. Shadowing is the idiomatic way to take a value through a pipeline of transformations while keeping one tidy name and leaving the final binding immutable.
let x = x + 1 does not change x; it builds a new x from the old one and hides the old. The original value is untouched (it simply becomes unreachable by that name). That is why shadowing can change the type and why it works on bindings that were never mut. mut changes a value; shadowing replaces a name.
Use mut when one thing genuinely evolves over time: a running total, a cursor, a buffer you fill. Use shadowing when you are converting a value through stages (raw text, trimmed, parsed) and want the final result to stay immutable. Reaching for shadowing by default keeps more of your program frozen, which is the safer place to be.
Compile-time values: const and static
Sometimes a value is not a local at all but a fixed fact about the program: a maximum connection count, a protocol name, a version string. Go spells this const; Rust has two keywords for the job, and the difference between them is subtle but real. Run this:
// const is a compile-time constant: inlined wherever it is used, no storage
// of its own. The type annotation is required, and the name is SCREAMING_CASE.
const MAX_CONNECTIONS: u32 = 1024;
// const can live inside a function too, scoped to it.
fn capacity() -> u32 {
const HEADROOM: u32 = 64;
MAX_CONNECTIONS - HEADROOM
}
// static is a single value with a fixed address that lives for the whole
// program. Use it when you genuinely need one shared location, not just a
// constant. An immutable static is the common, safe case.
static GREETING: &str = "ready";
fn main() {
println!("{GREETING}: up to {MAX_CONNECTIONS} connections");
println!("usable capacity is {}", capacity());
// const is evaluated at compile time, so a const value never changes and
// is the same for every caller. This one inlines to 1024 at each use.
let limit = MAX_CONNECTIONS;
println!("limit constant inlines to {limit}");
}A const is a compile-time constant. The compiler evaluates it once and inlines its value wherever the name appears, so a const has no storage and no address of its own; it is a name for a literal computation. Two rules come with it: the type annotation is required (the compiler will not infer a constant for you), and the convention is SCREAMING_SNAKE_CASE. Constants can live at the top level or inside a function, as HEADROOM does here.
A static is different: it is a single value with one fixed address that exists for the entire run of the program. Where a const may be copied and inlined at many sites, a static is genuinely one location in memory. For the everyday case (an immutable shared value like GREETING) the two feel interchangeable, and you should prefer const unless you specifically need that single fixed address. The reason the distinction matters more than it looks is that a mutablestatic is shared mutable state, which Rust treats as genuinely dangerous; that thread leads into ownership and concurrency, and it is not this chapter’s to pull.
Reach for const for nearly every named constant: it is a compile-time value with no runtime footprint, like a Go const. Reach for static only when you truly need one shared location that lives for the whole program. Both require a written type, and both are evaluated before main ever runs.
The scalar types, exactly
Go gives you int, int64, float64, bool, rune, and friends. Rust’s set of single-value (scalar) types is in the same spirit but spelled with more precision, because Rust wants the exact width and signedness of every integer written down. Run this tour:
// The four scalar families: integers, floats, bool, char.
fn main() {
// Integers come in signed (i) and unsigned (u) flavours, at widths of
// 8, 16, 32, 64, and 128 bits, plus isize/usize that match the pointer
// width of the machine (usize is what indexing and lengths use).
let small: i8 = -128; // signed 8-bit: -128 ..= 127
let big: u64 = 18_446_744_073_709_551_615; // unsigned 64-bit max
let index: usize = 3; // pointer-width, for indexing
println!("i8 floor {small}, u64 ceiling {big}, usize {index}");
// The default integer type, when nothing forces otherwise, is i32.
let count = 100; // i32
println!("default integer literal is i32: {count}");
// Floats are f64 by default (the other choice is f32).
let ratio = 0.1 + 0.2; // f64; binary floats are not exact, as everywhere
println!("0.1 + 0.2 = {ratio}");
// bool is true or false, and it is its own type (not an integer).
let ready: bool = 8080 > 1024;
println!("port is unprivileged: {ready}");
// char is one Unicode scalar value, four bytes wide, in single quotes.
// It is not a byte: it can hold any code point, including an emoji.
let letter: char = 'R';
let crab: char = '\u{1F980}'; // the Rust crab, one char
println!("char is a Unicode scalar: {letter} and {crab}");
}Integers come as a grid of two choices. First, signed or unsigned: an i type holds negatives, a u type holds only zero and up. Second, the width in bits: 8, 16, 32, 64, or 128. So i8 runs from -128 to 127, u8 from 0 to 255, i32 is the workhorse signed type, u64 reaches past eighteen quintillion. Two extra types, isize and usize, match the pointer width of the target machine (64 bits on a modern laptop); usize is the type Rust uses for lengths and for indexing, so you will see it constantly. When a literal has nothing forcing its type, Rust defaults it to i32. The underscores in 18_446_744_073_709_551_615 are just digit separators the compiler ignores; they are there for your eyes.
Floats are f32 and f64, and an unannotated decimal literal defaults to f64. They are IEEE-754 binary floats, the same ones every mainstream language uses, so 0.1 + 0.2 prints the familiar 0.30000000000000004: not a Rust quirk, just binary floating point being itself. bool is its own type holding true or false, and unlike C it is not interchangeable with an integer; you cannot add it to a number or use a number where a bool is wanted.
char is the one that catches Go developers. A Rust char, in single quotes, is exactly one Unicode scalar value (any code point that is not a surrogate), and it is four bytes wide, so a single char can hold 'R'or the crab emoji alike. It is not a byte and not a unit of a string’s storage; a string is UTF-8 bytes, and how char relates to that is the subject of the strings chapter later in Part III. For now: single quotes mean one Unicode character, double quotes mean a string.
In C and in many languages a character is a byte. In Rust a char is a four-byte Unicode scalar, so 'a', 'é', and a crab emoji are all one char. If you actually want a single byte, that is u8, written b'a'. Single quotes give you a Unicode character; double quotes give you a string.
Overflow is not silent
Here is a place Rust behaves differently from C, from Go, and from most of what you have used, so it earns its own section. Ask what 255u8 + 1 should be. In C it silently wraps to 0 and your program carries on with a wrong number. In Go it also wraps. Rust refuses to be quietly wrong. Run this, which uses the explicit, unambiguous methods rather than a bare +:
// Integer overflow is defined behaviour, and Rust does not let it pass quietly.
fn main() {
let max: u8 = 255; // a u8 holds 0 ..= 255
// In a debug build, `max + 1` panics with "attempt to add with overflow".
// In a release build (optimised), the same add wraps around to 0 instead.
// Because that difference is a trap, the standard library gives you
// explicit methods that mean exactly one thing in BOTH build modes.
// wrapping_*: deliberately wrap around, modulo the type's range.
println!("wrapping: 255 + 1 = {}", max.wrapping_add(1)); // 0
// checked_*: return Option, None when it would overflow.
println!("checked over: {:?}", max.checked_add(1)); // None
println!("checked under: {:?}", max.checked_add(0)); // Some(255)
// saturating_*: clamp to the type's edge instead of wrapping.
println!("saturating: {}", max.saturating_add(50)); // 255
// overflowing_*: hand back the wrapped value and a "did it wrap?" flag.
let (value, wrapped) = max.overflowing_add(1);
println!("overflowing: value {value}, wrapped {wrapped}"); // 0, true
}The behaviour of a plain + that overflows depends on how you compiled. In a debug build (the default while you are developing), 255u8 + 1 panics with attempt to add with overflow: the program stops loudly at the exact line rather than continuing with a corrupt value. In a release build (optimised, what you ship), that same overflow check is removed for speed and the add wraps to 0. The Workbench runs a debug build, so a bare overflowing add here would halt the program.
Because that debug-versus-release split is itself a trap, the standard library gives every integer four families of methods that mean exactly one thing in both modes, so you never have to wonder. wrapping_addalways wraps around modulo the type’s range. checked_add returns an Option: None when it would overflow, Some(n) when it fits (there is the Option from I.02, earning its keep). saturating_add clamps to the type’s edge instead of wrapping. overflowing_add hands back the wrapped value together with a bool telling you whether it wrapped.
Bare arithmetic is for values you are certain stay in range; if they might not, it panics in debug and wraps in release. When overflow is a real possibility, say what you mean: checked_ to detect it (you get an Option), saturating_ to clamp, wrapping_ to wrap on purpose. The method name documents the intent at the call site.
Grouping values: tuples and arrays
Before you reach for a struct, Rust gives you two lightweight ways to group values. You have met both shapes in other languages: a tuple (Python’s (a, b), Go’s multiple return values) and a fixed-size array. Run this:
// Two compound primitives that group values without needing a struct.
fn main() {
// A tuple is a fixed-length, ordered group whose parts may differ in type.
let endpoint: (&str, u16, bool) = ("api", 8080, true);
// Reach a field by position with .0, .1, .2 (positions, not names).
println!("host {} port {} tls {}", endpoint.0, endpoint.1, endpoint.2);
// Or destructure the whole thing into names in one binding.
let (host, port, tls) = endpoint;
println!("destructured: {host}:{port} (tls={tls})");
// The empty tuple () is a real type called "unit": the value a block
// produces when it has nothing else to say. You will see it everywhere.
let nothing: () = ();
println!("unit prints as: {nothing:?}");
// An array is a fixed-length run of ONE type, written [T; N].
let ports: [u16; 3] = [80, 443, 8080];
println!("first port {}, there are {}", ports[0], ports.len());
// The repeat form [value; count] fills N copies without typing them out.
let zeroed = [0u8; 4]; // four zero bytes
println!("repeat form gives {zeroed:?}");
// Indexing is bounds-checked: an out-of-range index panics rather than
// reading past the end the way C would. (ports[9] would panic here.)
let total: u16 = ports.iter().sum();
println!("sum of ports is {total}");
}A tuple is a fixed-length, ordered group whose elements may each be a different type: (&str, u16, bool) here. You reach an element by its position with a dot and a number, endpoint.0 and endpoint.1, counting from zero. More often you destructure it, binding every field to a name in one move: let (host, port, tls) = endpoint. If Go lets you return (value, err), a Rust tuple is that same idea as a first-class value you can store, pass, and pattern-match.
The smallest tuple deserves its own mention: (), the empty tuple, is a real type called unit. It is the value of anything that has nothing meaningful to produce, and you saw it already as the return type of a function that only prints. Keep it in mind; it shows up the moment you start asking what a block or a statement evaluates to in the next section.
An array is a fixed-length run of values that are all the same type, written with the type [T; N]: [u16; 3] is exactly three u16s, and that length is part of the type. You index with ports[0], and the repeat form [0u8; 4] builds four copies of a value without typing them out. Indexing is bounds-checked: reading ports[9] on a three-element array does not wander off into memory the way C would; it panics, turning a silent corruption into a clean stop. The growable, heap-backed cousin you will actually reach for most of the time, Vec<T>, comes in Part IV; an array is the fixed-size, stack-friendly building block underneath it.
[u16; 3] and [u16; 4] are different types; a function taking one will not accept the other. That is what makes arrays fixed-size. When you want a sequence whose length is not known until runtime, you use a slice (&[u16], a borrowed view) or a Vec<u16> (a growable, owned buffer), both of which are later in Part III and IV.
Almost everything is an expression
Here is the idea that gives Rust code its particular shape, and it is worth slowing down for. In Go, an if is a statement: it does something but produces no value, so to choose a value you declare a variable above the if and assign into each branch. In Rust, an if is an expression: it evaluates to a value you can bind directly. Almost every construct in Rust works this way. Run this and watch values fall out of blocks and ifs:
// In Rust almost everything produces a value. The rule that makes this work:
// a block's value is its final expression, written WITHOUT a semicolon.
fn main() {
// A block { ... } is itself an expression. The last line with no
// semicolon is the value the whole block evaluates to.
let label = {
let port = 8080;
let kind = if port < 1024 { "privileged" } else { "user" };
// No semicolon here, so this string is the value of the block.
format!("port {port} is {kind}")
};
println!("{label}");
// if/else is an expression too, so you assign its result directly.
// Both arms must produce the same type (here, &str).
let scheme = if 8080 == 443 { "https" } else { "http" };
println!("scheme is {scheme}");
// The difference between a statement and an expression is the semicolon.
// `let x = 5;` is a statement and yields nothing. `5` is an expression
// and yields 5. Adding a semicolon turns an expression into a statement
// whose value is the unit value ().
let doubled = { let n = 21; n * 2 }; // block's value is 42
println!("doubled is {doubled}");
// A trailing semicolon would make the block yield () instead, which is
// why a stray semicolon on the last line is a classic type mismatch.
}The rule underneath all of it is small: a block (any { ... }) is an expression, and its value is its final expression written without a semicolon. So the block bound to label runs three lines and then yields the format! result on its last line, because that line has no semicolon. Because if/else is also an expression, you write let scheme = if ... { ... } else { ... } and bind the chosen value straight across, with one constraint: both arms must produce the same type, since the binding has one type.
This is also where the semicolon earns real meaning. A statement performs an action and yields nothing; an expression evaluates to a value. let x = 5; is a statement. The 5 inside it is an expression. Adding a semicolon to the end of an expression turns it into a statement whose value is (), the unit value from the last section. That single fact explains the most common beginner compile error in Rust: put a semicolon after the last line of a function that should return a value, and the block now yields () instead, and the types no longer match.
The last line of a block is its value only if it has no semicolon. Write x + 1 and the block is x + 1; write x + 1; and the block is (), because the semicolon discarded the value. When the compiler says it expected i32, found (), look for a semicolon you should delete on the final line.
Loops and control flow
Rust has three loops where Go folds everything into one for, and one of them does something you have probably never seen a loop do: hand back a value. Run this, then we will name each piece:
// Rust has three loops. One of them can hand back a value when it stops.
fn main() {
// `loop` runs forever until you `break`. Unlike most languages, break
// can carry a value, making loop an expression that produces a result.
let mut attempt = 0;
let connection = loop {
attempt += 1;
if attempt == 3 {
break format!("connected on attempt {attempt}");
}
};
println!("{connection}");
// `while` runs while a condition holds, and yields nothing (unit).
let mut countdown = 3;
while countdown > 0 {
println!("t-minus {countdown}");
countdown -= 1;
}
// `for` walks an iterator. A range a..b iterates a up to (not including) b.
let mut sum = 0;
for n in 1..=5 { // ..= includes the upper bound, so 1,2,3,4,5
sum += n;
}
println!("sum 1..=5 is {sum}"); // 15
// for also walks any iterable, like the elements of an array.
for port in [80, 443, 8080] {
if port == 443 { continue; } // skip the rest of THIS turn
if port > 1000 { break; } // stop the loop entirely
println!("checking port {port}");
}
// A label (a name like 'outer) lets break and continue target an OUTER
// loop from inside an inner one, instead of just the nearest loop.
let mut found = None;
'rows: for row in 0..3 {
for col in 0..3 {
if row + col == 3 {
found = Some((row, col));
break 'rows; // leaves BOTH loops at once
}
}
}
println!("first pair summing to 3: {found:?}");
}loop is an infinite loop you leave with break. The surprise is that break can carry a value, so loop is an expression too: the whole loop { ... } evaluates to whatever you break with, which is how connection ends up holding the string from the third attempt. Use this for retry-until-success: run until a condition is met, then break out the result. while is the familiar condition-controlled loop, and it yields nothing useful (()), so it is for repeating side effects, not producing a value.
for is the one you will write most, and it walks an iterator: anything that can hand out one element at a time. A range is the simplest source: 1..5 is the half-open range 1, 2, 3, 4 (the end is excluded, matching slicing conventions you know), while 1..=5 with the equals is inclusive and adds the 5. A for also walks the elements of an array directly, as the [80, 443, 8080] loop shows. Inside any loop, continue skips to the next turn and break stops the loop.
The last piece is labels. By default break and continue act on the nearest loop, which is a problem when you are nested and want to leave an outer one. Give a loop a label, an apostrophe and a name like 'rows, and then break 'rows leaves that loop from however deep inside you are. It is the structured, readable answer to the thing other languages reach for goto or a flag variable to do.
loop repeats until break, and break value makes the loop an expression that yields that value. while cond repeats while the condition holds. for item in iter walks a range or any iterable. Label a loop ('name:) to break or continue an outer loop from inside an inner one.
Functions, the same rules one more time
You have already read a dozen fns in this chapter; now here are the rules spelled out. A function declares typed parameters and, after an arrow, its return type. Because a function body is just a block, the expression rule from section 07 applies unchanged: the last line without a semicolon is the return value. Run this:
// Functions take typed parameters and declare their return type after ->.
// The body is a block, so the same trailing-expression rule applies: the
// last expression without a semicolon IS the return value, no `return` needed.
/// Doc comments start with three slashes and describe the item below them.
/// This one says: add two numbers and give back the sum.
fn add(a: i32, b: i32) -> i32 {
a + b // no semicolon, so this is the returned value
}
// You can also return early and explicitly with `return`, which is handy for
// guard clauses that bail out before the main work.
fn clamp_port(requested: u32) -> u32 {
if requested == 0 {
return 1; // early exit; everything below is skipped
}
if requested > 65535 {
return 65535;
}
requested // the final-expression return for the normal path
}
// A function with no -> returns the unit value (), the do-nothing result.
// Note the // line comment and the /* block comment */ both below.
fn announce(port: u32) {
/* This whole function just prints; it has no useful value to return,
so its return type is the implicit unit type (). */
println!("listening on {port}");
}
fn main() {
let total = add(20, 22);
println!("add(20, 22) = {total}");
println!("clamp_port(0) = {}", clamp_port(0));
println!("clamp_port(8080) = {}", clamp_port(8080));
println!("clamp_port(99999) = {}", clamp_port(99999));
announce(total as u32);
}Read the signatures. fn add(a: i32, b: i32) -> i32 takes two i32s and returns an i32. Every parameter is annotated; Rust never infers parameter types, because the signature is a contract for callers to read (the same lesson as I.02, now applied to the inputs). The body is a + b with no semicolon, so that sum is the return value. There is no return keyword needed for the normal path, and writing a + b; with a semicolon would break it, because the block would then yield () and not match the declared -> i32.
You still have an explicit return, and it is for leaving early. clamp_port uses two guard clauses: if the input is 0 or above the maximum, return a corrected value right away and skip the rest. The normal path at the bottom falls through to the final-expression return. Mixing the two like this, return for the edge cases and a trailing expression for the main result, is idiomatic and reads cleanly. A function with no arrow, like announce, returns (): it exists for its side effect, not a value.
The comments in that program also quietly cover the last item on the list. // begins a line comment, /* ... */ is a block comment that can span lines, and /// with three slashes is a doc comment: it documents the item directly below it and is what the cargo doc tool turns into your API documentation. You will meet doc comments properly when the book reaches tooling; for now, recognise the triple slash as documentation rather than an ordinary remark.
Parameters are always typed; the return type follows ->. The body is a block, so its last semicolon-free expression is the return value, no return required. Use return for early exits (guard clauses); let the final expression carry the normal result. No arrow means the function returns the unit value ().
One thread runs ahead of this chapter on purpose, and it is the whole of the next one. When you pass a value into a function or bind it to another name, that can move the value: the original binding may no longer be usable afterward. We have leaned on that word nowhere here, and every example was built to sidestep it, but it is the rule that governs all the syntax you just learned. Part III, chapter 02 is where moves get their name and their reasons.
The whole chapter in seven lines
letbinds a name and infers its type, and the binding is immutable by default; you opt into change.mutchanges a value in place; shadowing (a freshletreusing the name) replaces the binding and may even change its type.- The scalars are sized integers (
i8toi128,u8tou128,isize/usize), floats (f32,f64),bool, and a four-byte Unicodechar. - Overflow is not silent: a bare overflow panics in debug and wraps in release, so reach for
checked_/saturating_/wrapping_when it can happen. - Tuples group mixed types by position; arrays
[T; N]are fixed-length runs of one type, bounds-checked on access. - Almost everything is an expression: a block’s value is its final semicolon-free line, and
ifandloopevaluate to values you can bind. loop/while/forcover repetition (breakcan carry a value, labels reach outer loops), and functions type every parameter and return their final expression.