Viscosity
Review
Part IX · Rust for TypeScript developers · Day 31

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.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • 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.

Intuition

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:

Step throughLocked boxes, open boxes, new boxes
let x = 5;
x = 6; // rejected
let mut n = 0;
n += 1;
n += 1;
let x = x * 10; // shadowing
let x = "fifty";
Variables as boxes: locked without mut, open with mut, new ones for shadowingx5i32locked
let x = 5 makes a box called x holding 5. Without mut, the box is locked: like const in TypeScript, x can never be given another value.
1/7

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.

Definition

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:

Byte widths of TypeScript's number and Rust's scalar typesone square = one bytenumberTypeScript: always float64u80 to 255: one bytei32the default integeri64bigger whole numbersusizeindexes and lengthsf64same as a JS numberbooltrue or falsecharone Unicode scalar value
Rust's number types say how many bytes a value takes. Sizes on a 64-bit machine, from std::mem::size_of with rustc 1.97. usize matches the machine's pointer width.
  • Signed integers i8, i16, i32, i64, i128 hold negative and positive whole numbers. Unsigned u8 to u128 hold zero and up. The number is the width in bits.
  • usize is unsigned and as wide as an address. Indexes and lengths use it.
  • f64 is exactly JavaScript’s number. f32 is half the size.
  • bool is true or false. char is 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;
Definition

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

Pitfall

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.

Try itNumbers with types
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}");
}
Open in Rust Playground ↗
Under the hood

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

ExerciseThe mean, without losing the half

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!()
}
Open in Rust Playground ↗
Extra practiceA counter that wraps like a byte

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!()
}
Open in Rust Playground ↗

What you learned

  • let is TypeScript’s const; let mut is TypeScript’s let. Immutability covers the whole value, not just the arrow.
  • Shadowing with a second let makes a new variable, possibly of a new type. mut changes the value in the same variable.
  • The scalar types: i32 (default), u8, i64, usize for indexes, f64 (JavaScript’s number), bool, char.
  • No silent conversions: use as. Integer division truncates. Overflow panics in debug builds; wrapping_add wraps on purpose.

Next: functions, and why almost everything in Rust is an expression.

Check your understanding

0/6
  1. 1.

    Does this compile?

    let total = 0;
    total += 5;
  2. 2.

    What does this print?

    let x = 4;
    let x = x + 1;
    let x = x * 2;
    println!("{x}");
  3. 3.

    Which type would you use for an index into a Vec?

  4. 4.

    What is -7 / 2 in Rust (both i32)?

  5. 5.

    A u8 variable holds 255 and your code adds 1. You run the tests (a debug build). What happens?

  6. 6.

    Why doesn’t let a: i32 = 1; let b: f64 = 2.5; a + b compile?

References

  1. [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)