122. Variables, types and mutability
Rust’s let is TypeScript’s const, and its one number type becomes a dozen. Both changes are the compiler asking you to say what you mean.
- Declare variables with let and let mut, and say which TypeScript keyword each matches.
- Tell shadowing apart from mutation.
- Choose between i32, u8, i64, usize, f64, bool and char.
- Convert between number types with as, and predict integer division and overflow.
A counter that won’t count
Here is the first loop counter anyone writes, in Rust:
fn main() {
let x = 5;
x = 6;
println!("{x}");
}
In TypeScript, let x = 5; x = 6; is fine. rustc 1.97 refuses (its
messages in this book are real, wrapped to fit your screen):
error[E0384]: cannot assign twice to
immutable variable `x`
--> e2.rs:3:5
|
2 | let x = 5;
| - first assignment to `x`
3 | x = 6;
| ^^^^^ cannot assign twice to
| immutable variable
|
help: consider making this binding mutable
|
2 | let mut x = 5;
| +++
({x} inside the string prints the variable x.) The fix is in the
help: line, and it tells you the whole first idea of this chapter.
In Rust, every variable is a box that locks itself as soon as you put
something in it. If you know a box’s contents will change, you ask for
an unlocked one up front. The word you write is mut, and anyone
reading the code sees at a glance which boxes change.
let and let mut
Step through it:
let x = 5;x = 6; // rejectedlet mut n = 0;n += 1;n += 1;let x = x * 10; // shadowinglet x = "fifty";
The mapping from TypeScript is short:
// TypeScript // Rust
const x = 5; let x = 5;
let y = 5; let mut y = 5;
Rust makes the safe choice the short one. Most variables never change, and the compiler can then promise you that they don’t.
let binds a name to a value. Without mut the binding is
immutable: assigning to it again is a compile error. let mut makes
it mutable. A new let with an existing name shadows the old
variable: it makes a new variable, which can even have a new type.
One difference from const matters a lot. In chapter 4, const
froze only the arrow: you could still push onto a const array. In
Rust, an immutable binding covers the value too:
let xs = vec![1, 2];
xs.push(3);
error[E0596]: cannot borrow `xs` as
mutable, as it is not declared as mutable
vec![1, 2] makes a growable array, a Vec (chapter 130). “Borrow” is
chapter 125’s word. For now read it as: changing xs needs let mut xs. (Chapter 137 meets the few types that bend this rule.)
Constants
Rust also has const, for values fixed before the program runs. The
type is required, and the name is in capitals. Here is one from
drip-rs’s compiler:
const UINT8_COUNT: usize = 256;
That’s the limit you met in chapter 46: one byte can name only 256
constants or locals. A const may be declared outside every function,
and is usable anywhere in its scope[1].
One number becomes many
TypeScript has one number, and it is always a 64-bit float (chapter
22). V8 quietly stores small integers as Smis when it can, but that’s
the engine’s choice, not yours. Rust makes you choose:
- Signed integers
i8,i16,i32,i64,i128hold negative and positive whole numbers. Unsignedu8tou128hold zero and up. The number is the width in bits. usizeis unsigned and as wide as an address. Indexes and lengths use it.f64is exactly JavaScript’s number.f32is half the size.boolistrueorfalse.charis one Unicode scalar value in four bytes, written in single quotes:'é'. That’s not a JavaScript string index, which gives a UTF-16 code unit (chapter 10).
You rarely write types on let. Rust infers them, and falls back to
i32 for whole numbers and f64 for decimals[1]:
let n = 5; // i32
let f = 2.5; // f64
let b: u8 = 255; // annotated
let i: usize = 3;
The scalar types hold a single value: the integers i8 to i128
and u8 to u128, plus isize and usize, the floats f32 and
f64, bool and char. Integer literals default to i32 and
decimals to f64.
No silent conversions
Adding an i32 to an f64 is an error:
error[E0277]: cannot add `f64` to `i32`
--> e4.rs:4:22
|
4 | println!("{}", a + b);
| ^ no implementation
| for `i32 + f64`
You convert with as, and you say exactly where:
let a: i32 = 7;
let b: f64 = 2.0;
println!("{}", a as f64 / b); // 3.5
println!("{}", a / 2); // 3
That last line is a trap for TypeScript hands. Dividing two integers
gives an integer, cut toward zero: 7 / 2 is 3, and -7 / 2 is
-3. as can lose information too: 3.9_f64 as i32 is 3, and an
i32 holding 300 becomes 44 as a u8.
What about a u8 holding 255, plus one? In a debug build the program
stops with a panic, attempt to add with overflow. In a release build
it wraps around to 0[1]. If you want wrapping,
say so: x.wrapping_add(1).
Never rely on release builds wrapping. Debug builds panic at the same
spot, and the overflow is a bug either way. Use wrapping_add and
friends to ask for wrapping on purpose.
fn main() {
let a: i32 = 7;
let b: f64 = 2.0;
println!("{}", a / 2);
println!("{}", a as f64 / b);
println!("{}", -7 / 2);
let big: i32 = 300;
println!("{}", big as u8);
let r: u8 = 255;
println!("{}", r.wrapping_add(1));
println!("{}", u8::MAX);
println!("{}", i32::MAX);
let mut n = 0;
n += 1;
let n = n * 10;
println!("{n}");
}Your TypeScript VM stored bytecode in a number[], so each byte was,
as far as the type said, a float64 that happened to hold a byte. In drip-rs the chunk’s
code is a Vec<u8>: one byte per byte, as chapter 46 drew it. Line
numbers are a Vec<usize>. And a Drip number is still an f64, because
Drip numbers behave like JavaScript’s.
Your turn
A benchmark harness adds up its run times in whole milliseconds (an i32) and counts its runs (another i32). Write mean(total, count) so it returns the mean as an f64, keeping the fraction: mean(7, 2) is 3.5.
fn mean(total: i32, count: i32) -> f64 {
todo!()
}Drip bytecode is a list of u8s. Write next(x), which returns x + 1 as a u8, except that next(255) must be 0 (wrap around, like a one-byte counter), in debug and release builds.
fn next(x: u8) -> u8 {
todo!()
}What you learned
letis TypeScript’sconst;let mutis TypeScript’slet. Immutability covers the whole value, not just the arrow.- Shadowing with a second
letmakes a new variable, possibly of a new type.mutchanges the value in the same variable. - The scalar types:
i32(default),u8,i64,usizefor indexes,f64(JavaScript’s number),bool,char. - No silent conversions: use
as. Integer division truncates. Overflow panics in debug builds;wrapping_addwraps on purpose.
Next: functions, and why almost everything in Rust is an expression.
Check your understanding
0/6- 1.
Does this compile?
let total = 0; total += 5; - 2.
What does this print?
let x = 4; let x = x + 1; let x = x * 2; println!("{x}"); - 3.
Which type would you use for an index into a
Vec? - 4.
What is
-7 / 2in Rust (bothi32)? - 5.
A
u8variable holds 255 and your code adds 1. You run the tests (a debug build). What happens? - 6.
Why doesn’t
let a: i32 = 1; let b: f64 = 2.5; a + bcompile?
References
- [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)