5.1 Integer Types: i8 Through i128, u8 Through u128, isize, usize
Alright, let’s talk about the building blocks of numbers in Rust. This isn’t your high school algebra class; we’re dealing with the raw, unforgiving metal of the machine. Rust forces you to be explicit about your numbers because it values correctness over convenience, a trade-off you’ll learn to love (or at least respect).
The first thing to know is the great divide: signed and unsigned integers. A signed integer (i8, i16, i32, i64, i128) can be negative, zero, or positive. An unsigned integer (u8, u16, u32, u64, u128) can only be zero or positive. Think of it like a i for “I can be negative” and a u for “uh, only positive, please.” The number (8, 16, 32, etc.) is the size in bits. More bits means you can count higher (or lower, in the case of i types) at the cost of using more memory. This isn’t just pedantry; getting it wrong can lead to catastrophic bugs.
The Standard Sizes: i8/u8 through i128/u128
Here’s the roster, from the diminutive to the gargantuan.
let tiny: i8 = -128; // The smallest value an i8 can hold.
let small_positive: u8 = 255; // The largest value a u8 can hold.
let standard: i32 = 1_000_000; // Underscores for readability? Chef's kiss.
let large: u64 = 18_446_744_073_709_551_615; // That's a lot of cats.
let absolutely_ridiculous: i128 = -170_141_183_460_469_231_731_687_303_715_884_105_728; // For when you're counting the national debt in pennies.
The default integer type, the one Rust infers if you just write let x = 42, is i32. This is a sane choice—it’s fast on pretty much every modern CPU architecture. But you should almost never rely on inference for public function signatures or struct fields. Be explicit. Your collaborators (and future you) will thank you.
The Architecture-Dependent Duo: isize and usize
Now meet the special ones: isize and usize. Their size depends on the architecture of the machine your program is compiled for. On a 32-bit system, they’{{< bibleref “Revelation 32
” >}} bits; on a 64-bit system, they’{{< bibleref “Revelation 64
” >}} bits. You might ask, “Why would I ever use these instead of a fixed size?” Excellent question.
You primarily use usize for one thing: indexing collections. The length of a Vec, the index of an array, the return value of std::mem::size_of—these are all usize. This makes perfect sense; the amount of memory you can index is dictated by your platform’s address space. Using u32 for an index on a 64-bit system is like using a toddler’s sippy cup to bail out a canoe—technically it might work for a bit, but it’s a fundamentally bad fit that will cause problems eventually.
let my_vec = vec![1, 2, 3, 4, 5];
// The .len() method returns a usize
let length: usize = my_vec.len();
// So you use a usize to index it
let first_element = my_vec[0]; // 0 is inferred to be a usize here
// This is why this common beginner mistake fails spectacularly:
// let index: u32 = 0;
// let element = my_vec[index]; // ERROR: expected `usize`, found `u32`
// The compiler is, as always, correct. Cast it: `my_vec[index as usize]`.
Overflow and Underflow: This Is Where the Fun Begins
Here’s the critical part, the thing Rust nags you about incessantly because it’s right. What happens when you try to store the value 256 in a u8? Or when you decrement 0 in a u8? This is overflow and underflow.
In debug mode, Rust is a helicopter parent. It panics. Your program will crash, loudly. This is a good thing! It’s catching your bug before it silently turns into a security vulnerability or incorrect calculation.
In release mode, Rust assumes you know what you’re doing (a terrifying assumption, I know). It performs two’s complement wrapping. For a u8, 255 + 1 wraps to 0, and 0 - 1 wraps to 255. This isn’t “undefined behavior” like in C++; it’s defined to wrap. But is it what you want? Almost never.
Rust gives you methods to handle this explicitly, forcing you to confront the problem:
wrapping_add: Explicitly wraps.checked_add: Returns anOption(Noneif overflow would occur).overflowing_add: Returns the result and a boolean telling you if overflow happened.saturating_add: Clamps at the maximum (or minimum) value.
let max_u8 = u8::MAX;
// let will_panic = max_u8 + 1; // Panic in debug, wrap in release.
let explicit_wrap = max_u8.wrapping_add(1); // 0
let checked = max_u8.checked_add(1); // None
let saturating = max_u8.saturating_add(1); // 255
Always ask yourself: “What should happen if this calculation goes out of bounds?” Then pick the method that matches. Don’t just let it wrap and hope for the best. Hope is not a strategy.
Literals and Suffixes
You can specify a literal’s type with a suffix. This is hugely helpful for avoiding confusing compiler errors about “ambiguous integer type.”
// Without suffixes, the compiler needs more context to infer the type.
let x = 10; // Inferred based on usage. Probably i32.
let y = 10u8; // Definitely a u8.
let z = 10_000_000_000u64; // Definitely a u64, because an i32 can't hold this.
Casting: A Necessary Evil
You will need to cast between integer types. Use as. But beware! as is a blunt instrument. Casting a large value to a smaller type silently truncates, which is a classic source of nasty bugs.
let large_number: u32 = 0x1234_5678;
let truncated: u16 = large_number as u16; // Now it's 0x5678. Oops.
When you use as, you are telling the compiler, “I, the programmer, have considered this conversion and take full responsibility for the consequences.” Make sure that’s true. For more complex, fallible conversions, look into TryFrom/TryInto, which return a Result and force you to handle the error case.