Sized Integers: Choosing Width, Signedness, and Conversions
Rust integers are explicitly sized and there is no implicit widening — the type is part of the value:
| Type | Range | Notes |
|---|---|---|
i8/u8 |
-128..127 / 0..255 | a u8 is "a byte" |
i16/u16 |
-32k..32k / 0..65k | |
i32/u32 |
±2.1 billion / 0..4.2B | i32 is the default for integers |
i64/u64 |
±9.2e18 / 0..1.8e19 | timestamps, IDs, accumulators |
i128/u128 |
±1.7e38 | money math, hashes |
usize/isize |
pointer-sized | indices, lengths; 32- or 64-bit per target |
Literals carry no type until inference assigns one, and every base is available:
let dec = 982;
let hex = 0xFF;
let oct = 0o77;
let bin = 0b1010_1010;
let byte = b'A'; // u8, ONLY valid for ASCII in byte literals
let big = 1_000_000; // underscores are digit separators
Conversions never happen implicitly. let sum: i64 = x + y; with x: i32 is error[E0308]: mismatched types, and the fix is an explicit as cast or the checked constructors:
let x: i32 = 1000;
let y = x as i64; // widening — safe
let t = 300_i32 as u8; // TRUNCATES to 44 — silent in release
let g = (-1_i32) as u32; // 4294967295 — two's complement reinterpretation
That truncation is legal Rust and a classic bug source: as is a reinterpretation of the low bits, not a range check. The safe API is i32::try_from(big).expect("fits") which returns Result (TryFromIntError), or .unwrap_or(u32::MAX) for saturating semantics. Rule of thumb used in review: casts in parsing/serialization boundaries are fine; casts buried in arithmetic get questioned.
Floats are f32 (single) and f64 (double, the default — let x = 3.14; is f64). Three float facts interviewers probe: integer division / between two floats still needs both sides float; 0.0 / 0.0 is NaN and NaN != NaN is true, so equality checks must use is_nan(); and floats are not Eq, so f64 keys in hash maps behave surprisingly. usize deserves one more warning: because it differs per target, code that assumes 64-bit usize breaks on 32-bit/WASM targets — do not store file offsets or money in it.
Two literal traps surface early. First, an out-of-range literal is rejected at compile time regardless of inference:
let x: u8 = 300; // error: literal out of range `300`
let y: i8 = -128; // fine — but 128_i8 is not, even before any arithmetic
Second, the default only applies when nothing else constrains the type — the moment a later line demands u16, the literal becomes u16:
let n = 5; // unconstrained → i32
let m: u16 = n; // error[E0308]: expected u16, found i32
Unsigned arithmetic has its own cliff, because subtraction below zero is overflow too:
let used = 10u8;
let free = used - 20; // debug: attempt to subtract with overflow
// release: 246
This is how a capacity check turns into a huge number in production — the guard used >= 20 must come before the subtraction, not after.
Floats need one concrete number to stay honest. 0.1 + 0.2 == 0.3 is false; the sum is 0.30000000000000004, because 0.1 and 0.2 are not representable in binary floating point. Compare with a tolerance ((a - b).abs() < 1e-9) and never use a float as a hash-map key unless you round to a quantized step first. Also remember f64 is the default float type — let x = 3.14; is f64, and writing 3.14f32 is how you ask for single precision, halving storage at roughly 7 significant digits instead of 15–17.
char Is Four Bytes; Tuples Are Heterogeneous Stacks of Values
char is a Unicode scalar value: any code point from 0 to 0x10FFFF excluding the surrogate range D800–DFFF. It is always four bytes in memory, unlike C's one-byte char:
let c = 'z';
let heart = '\u{2764}'; // escaped code point
let z: char = 'z';
let n = 42;
let bad = 'z' == n; // error: char vs integer are distinct types
println!("{} is {} bytes", c, std::mem::size_of::<char>()); // 4
The four-byte size is the footprint of the type, not of encoded text: UTF-8 strings store 1–4 bytes per character depending on the code point, and only char normalizes to a fixed slot. This split creates the question every Rust text-processing code must answer — are you working in characters or bytes?
let s = "héllo";
assert_eq!(s.len(), 6); // BYTES: é is 2 bytes in UTF-8
assert_eq!(s.chars().count(), 5); // characters
for b in s.bytes() { /* u8 iterator */ }
for c in s.chars() { /* char iterator */ }
b'A' and 'A' look alike and mean different things: the first is a u8, the second a char. A byte literal is legal only for ASCII code points; anything non-ASCII must be written as a char escape. Slicing a string by byte index can split a multi-byte character and panic — you will see this concretely in Slices as Fat References.
Tuples group a fixed, heterogeneous sequence under one value:
let pair: (i32, f64) = (42, 3.14);
let (num, pi) = pair; // destructuring — the idiomatic way to consume
let num = pair.0; // field access also exists
fn center() -> (f64, f64) { (0.5, 0.5) } // returning several values is a core use
let (x, y) = center();
Each element has its own type; the whole tuple has one size (sum of aligned elements). Tuples up to 12 elements have trait implementations (Debug, PartialOrd, …) — beyond that you need a struct. The empty tuple () is the unit type: exactly one value, also written (), with zero size — it is what functions return when they "return nothing" (fn log() {} really returns ()), and println! expands to code returning (). Option<()> and map keys of type () are rare but legal, and seeing -> () in generated code should not surprise you. Structs and enums wrap these primitives into named shapes next; the tuple is the anonymous, minimal version of the same idea.
char comes with a small standard toolkit that makes the four-byte cost worth paying:
let c = 'é';
assert!(c.is_alphabetic());
assert_eq!(c.to_ascii_uppercase(), 'É'); // no-op outside ASCII
assert_eq!(c as u32, 0xE9); // code point as number
assert_eq!(char::from_u32(0x1F600), Some('\u{1F600}')); // 😀
assert_eq!(char::from_u32(0xD800), None); // surrogate: not a scalar
That None is the definition made executable: a char can never hold a UTF-16 surrogate, so text produced through char is always valid Unicode. Going the other way — bytes to text — is std::str::from_utf8(&[u8]), which returns Result and rejects invalid sequences instead of producing mojibake.
One more layer exists above the scalar, and interviews about text almost always reach for it: grapheme clusters. The word "é" can be encoded as U+00E9 (one scalar) or as e plus U+0301 (two scalars). Both render identically, so "café".chars().count() is 4 or 5 depending on how the file was written, while a user perceives 4 characters. The unicode-segmentation crate counts what a human sees: s.graphemes(true).count(). The rule to carry: len() is bytes, chars() is scalars, graphemes() is user-perceived characters — pick the unit your operation actually means.
Tuples finish with a sizing detail: std::mem::size_of::<(i32, f64, u8)>() reports 16, not 13, because the layout pads to each element's alignment. When a size matters, the struct with #[repr(C)] is the tool that makes padding explicit and stable.
Overflow: Debug Panics, Release Wraps, and the Checked API
Integer overflow is where primitive types stop being trivia. Rust's rule: arithmetic overflow panics in debug builds and wraps in release builds, unless you ask for explicit behavior.
fn main() {
let mut v: u8 = 250;
v += 10; // debug: thread 'main' panicked at 'attempt to add with overflow'
// release: 4 (wraps modulo 256)
}
The debug panic is a gift — it catches the bug where it happens. The release wrap is the danger: the same code silently produces a wrong number in production. Debug builds run overflow checks (overflow-checks = true in the dev profile); release disables them along with assertions. The classic production bug is an index or counter that wraps to a small number and corrupts data. This is not hypothetical — it is the shape of real-world incidents where a length counter overflows and the program believes it holds a few items.
Rust therefore ships an explicit arithmetic API; every operation has four variants:
let a: u8 = 250;
assert_eq!(a.checked_add(10), None); // Option: None on overflow
assert_eq!(a.wrapping_add(10), 4); // modular wrap — deliberate
assert_eq!(a.saturating_add(10), 255); // clamp at the boundary
let (sum, overflowed) = a.overflowing_add(10); // (4, true) — both at once
The same quartet exists for sub/mul/div/abs. Choosing is a semantic decision: timestamps and IDs that must detect corruption use checked_* and handle None; hash functions and ring buffers want wraparound and use wrapping_*; clamping to a boundary is right for physical quantities (volume, percentage). Dividing by zero is always a panic, in every profile — there is no wraparound answer for it.
The compile-time side deserves mention: overflow in a const context is a compile error, not a runtime panic:
const OVER: u8 = 255_u8 + 1; // error[E0080]: evaluation of constant value failed
How to guard against the release-only wrap in practice. First, run your test suite under cargo test --release at least once — bugs that hide in profiles love CI that only tests debug. Second, treat as-casts plus arithmetic as a review target: x as u8 * 2 wraps silently in both profiles. Third, for anything counting untrusted input sizes, checked_* with an error path is the professional default. Overflow semantics are profile-dependent behavior — the same source code genuinely means different things in debug and release, and an interviewer asking "what happens when a u8 exceeds 255?" is probing exactly whether you know the two answers.
There is a middle setting between the two defaults, and production teams use it: keep overflow checks on in release and pay for them in speed.
[profile.release]
overflow-checks = true # panic on overflow even in release
The trade is quantifiable — checks add a compare and branch per operation, which measures as a low-single-digit percentage on arithmetic-heavy code and is usually unmeasurable on I/O-bound services — in exchange for a panic with a full backtrace instead of a silently wrong number. Teams shipping financial or capacity arithmetic often enable it; teams shipping hot numeric kernels usually leave it off and use checked_* in the few places that need it. Either way you have made the decision rather than inherited it.
The lint route targets the same risk at the call site: clippy::arithmetic_side_effects (a restriction lint, enabled per module with #[allow]/#[deny]) flags every bare + - * / % on integers, forcing each one to be a deliberate checked_, wrapping_, or saturating_ call. It is aggressive — it will flag most code — but for a payment or quota module it converts "did we think about overflow?" from a review question into a compile-time one.
Underneath all of these is the property worth stating in an interview: overflow in Rust is defined behavior in every profile — a panic, a wrap, or a compile error, never undefined behavior as in C — so the choice of which defined behavior you want is a design decision you own.
Quiz
1. In release mode, what does `(300_i32 as u8).wrapping_add(10)` evaluate to, and why is `as` relevant?
2. What does `assert_eq!("héllo".len(), s.chars().count())` compare, and why can they differ?
3. A counter increments in a release-only deployment and sometimes jumps to a tiny value after long uptime. Which explanation fits Rust's rules?
4. What does the type `()` — the empty tuple, or unit type — mean in a function signature like `fn log() -> ()`?
Flashcards
Question
What is the default integer type, and how do you convert between integer types?
Click to reveal answer
Answer
i32 is the default for integer literals under inference. Conversions are always explicit: `x as i64` for casts (truncates silently when narrowing!) or i64::try_from(x) which returns Result and range-checks — the safe choice at trust boundaries.
Question
char vs u8 vs &str — what does each represent?
Click to reveal answer
Answer
char: one Unicode scalar value, always 4 bytes in memory. u8: an integer 0–255; b'A' is a u8 byte literal (ASCII only). &str: a borrowed UTF-8 string slice whose bytes encode 1–4 bytes per character — len() counts bytes, chars() counts scalars.
Question
What happens on integer overflow in debug vs release, and the const context?
Click to reveal answer
Answer
Debug: panic ('attempt to add with overflow'), overflow-checks on. Release: silent wrap modulo 2^N. Const evaluation: compile error (E0080). Explicit ops override all profiles: checked_* (Option), wrapping_* (modular), saturating_* (clamp), overflowing_* (value + flag).
Question
Why does "héllo".len() == 6 while s.chars().count() == 5?
Click to reveal answer
Answer
len() counts UTF-8 BYTES — é encodes as 2 bytes (0xC3 0xA9), so h(1)+é(2)+l(1)+l(1)+o(1)=6. chars() iterates Unicode scalars: 5. Any text indexing must pick a unit deliberately; byte slicing mid-character panics.
Question
What is the unit type, and where does it appear?
Click to reveal answer
Answer
() — the empty tuple: one value, zero size. It is what `fn f() {}` actually returns, the result of println! expressions, and the element type of Vec<()> (a zero-sized type). Distinct from 'no value': Option<()> is still an Option.
Revision Notes
Key Takeaways
- 1. Integers never implicitly widen; `as` reinterprets low bits (silent truncation) while try_from range-checks — choose deliberately at boundaries
- 2. char is a 4-byte Unicode scalar; String stores UTF-8 bytes, so len() and chars().count() answer different questions and byte-slicing mid-character panics
- 3. Overflow panics in debug, wraps in release, and fails compilation in const contexts — profile-dependent behavior you must know before writing counters or indices
- 4. The explicit arithmetic API (checked_/wrapping_/saturating_/overflowing_) replaces profile luck with declared semantics
- 5. Tuples group heterogeneous values and destructure with patterns; the unit type () is a real zero-sized value, the return of every void function
Interview Tips
- • "What does overflow do in Rust?" — the three-context answer (debug panic, release wrap, const compile error) plus the explicit ops; ending with 'I run tests in release too' separates you from candidates who only quote the panic
- • "Why is char 4 bytes?" — Unicode scalar value, not encoded byte; UTF-8 compresses storage in strings but char normalizes to one slot; the surrogate-D800..DFFF exclusion proves you know it is a scalar, not a code unit
- • "u8 vs char for ASCII data?" — b'A' is u8 (byte, works with &[u8]); 'A' is char (scalar, works with text APIs); parsing bytes to chars needs encoding awareness via str::from_utf8
- • "How do you safely convert a large i64 ID to usize on a 32-bit target?" — try_from with error handling, because usize width is target-dependent and as-casts truncate; mention WASM32 as the concrete 32-bit case
Cheat Sheet
Primitive Types and Overflow
Integers: i8/16/32/64/128, u*, usize/isize (pointer-sized). Default literal type: i32. Literals: 0xFF 0o77 0b1010 b'A'(u8) 1_000_000. Conversions explicit: as (truncates!) vs try_from (Result).
Floats: f32/f64 (default f64). NaN != NaN → use .is_nan(); not Eq → hash-map foot-gun.
char: Unicode scalar, 4 bytes in memory; UTF-8 strings store 1–4 bytes per char:
"héllo".len() // 6 — bytes
"héllo".chars().count() // 5 — scalars
Tuples: fixed heterogeneous groups; destructure with let (a, b) = t;; up to 12 elems get std traits; () = unit type, zero-sized, the true return of void functions.
Overflow semantics:
| Context | Behavior |
|---|---|
debug (cargo test) |
panic: 'attempt to add with overflow' |
| release | wrap mod 2^N — silent |
| const | compile error E0080 |
checked_add |
Option — None on overflow |
wrapping_add |
deliberate modular |
saturating_add |
clamp at boundary |
overflowing_add |
(value, flag) |
Guard: run tests with cargo test --release at least once.